<?php
namespace Bookaro\Service;

defined( 'ABSPATH' ) || exit;

use Bookaro\Repository\BookingRepository;
use Bookaro\Utils\Logger;
use Bookaro\Utils\BookingPolicyPresenter;
use Bookaro\Utils\UserHelper;
use Bookaro\Contracts\CalendarSyncInterface;
use Bookaro\Contracts\MessagingGatewayInterface;
use Bookaro\Contracts\PaymentsGatewayInterface;
use function bookaro_get_option;
use function bookaro_get_review_settings;
use function wp_timezone;

/**
 * Core booking business logic: creation, availability checks and
 * status transitions. AJAX controllers delegate to this service.
 */
class BookingService
{
    private BookingRepository $db;
    private ScheduleService $schedule;
    private EmailService $emails;
    private CalendarSyncInterface $gcal;
    private MessagingGatewayInterface $messages;
    private PaymentsGatewayInterface $payments;
    private static ?bool $lock_supported = null;

    /**
     * Bug 35: hard cap on user-submitted free-text fields (notes,
     * cancel_reason) before they hit the DB. The `notes` and
     * `cancel_reason` columns are TEXT (~65,535 bytes), so anything
     * larger was being silently truncated by MySQL in non-strict mode,
     * or erroring out (and swallowed) in strict mode. Cap at a
     * predictable boundary the API can document — 5,000 characters is
     * well beyond what a real human types into a booking note and still
     * leaves room for multi-byte UTF-8. Sites that need a different
     * limit can override via the bookaro_max_note_length filter.
     */
    private const NOTE_MAX_LENGTH = 5000;

    public function truncate_user_text( string $value ): string
    {
        $limit = (int) apply_filters( 'bookaro_max_note_length', self::NOTE_MAX_LENGTH );
        if ( $limit < 1 ) {
            return $value;
        }
        if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' ) ) {
            if ( mb_strlen( $value, 'UTF-8' ) > $limit ) {
                return mb_substr( $value, 0, $limit, 'UTF-8' );
            }
            return $value;
        }
        if ( strlen( $value ) > $limit ) {
            return substr( $value, 0, $limit );
        }
        return $value;
    }

    public function __construct(
        BookingRepository $db,
        ScheduleService $schedule,
        EmailService $emails,
        CalendarSyncInterface $gcal,
        MessagingGatewayInterface $messages,
        PaymentsGatewayInterface $payments
    )
    {
        $this->db       = $db;
        $this->schedule = $schedule;
        $this->emails   = $emails;
        $this->gcal     = $gcal;
        $this->messages = $messages;
        $this->payments = $payments;
    }

    private function dispatch_notification_event( string $event_key, int $booking_id, array $context = [] ): void
    {
        $context['booking_id'] = $booking_id;
        do_action( 'bookaro_booking_notification_event', $event_key, $booking_id, $context );
    }

    private function format_date_range( string $date, ?string $date_to ): string
    {
        if ( ! empty( $date_to ) ) {
            return sprintf( '%s to %s', $date, $date_to );
        }
        return $date;
    }

    private function get_booking_value( $booking, string $field, $default = null )
    {
        if ( is_array( $booking ) ) {
            return array_key_exists( $field, $booking ) ? $booking[ $field ] : $default;
        }

        if ( is_object( $booking ) && isset( $booking->{$field} ) ) {
            return $booking->{$field};
        }

        return $default;
    }

    private function resolve_action_actor_role( $booking, int $user_id ): string
    {
        $provider_id = (int) $this->get_booking_value( $booking, 'provider_id', 0 );
        $customer_id = (int) $this->get_booking_value( $booking, 'customer_id', 0 );

        if ( $user_id > 0 && $user_id === $provider_id ) {
            return 'provider';
        }

        if ( $user_id > 0 && $user_id === $customer_id ) {
            return 'customer';
        }

        if ( $user_id > 0 && function_exists( 'current_user_can' ) && current_user_can( 'manage_options' ) ) {
            return 'admin';
        }

        return 'unknown';
    }

    private function build_policy_status_message( string $status ): string
    {
        return BookingPolicyPresenter::build_booking_status_policy_message( $status );
    }

    private function get_terminal_action_message( string $status, string $action ): string
    {
        return BookingPolicyPresenter::build_terminal_action_message( $status, $action );
    }

    private function build_cancel_policy_snapshot( $booking, string $status, string $actor_role ): array
    {
        $window_hours      = absint( bookaro_get_option( 'booking_cancel_window_hours', 0 ) );
        $apply_to_providers = (bool) bookaro_get_option( 'booking_cancel_policy_apply_providers', 1 );
        $window_applies    = $window_hours > 0 && ( $actor_role !== 'provider' || $apply_to_providers );
        $custom_template   = (string) bookaro_get_option( 'booking_cancel_policy_text', '' );
        $status            = BookingPolicyPresenter::normalize_status( $status );
        $start_raw         = (string) $this->get_booking_value( $booking, 'booking_date', '' );
        $cutoff_label      = '';
        $allowed           = ! in_array( $status, [ 'cancelled', 'rejected', 'completed', 'no_show' ], true );

        if ( $allowed && $window_applies && $start_raw !== '' ) {
            try {
                $tz       = wp_timezone();
                $start_dt = new \DateTime( $start_raw, $tz );
                $cutoff   = ( clone $start_dt )->modify( '-' . $window_hours . ' hours' );
                $now      = new \DateTime( 'now', $tz );
                $cutoff_label = BookingPolicyPresenter::format_datetime_label( $cutoff->format( 'Y-m-d H:i:s' ) );
                if ( $now > $cutoff ) {
                    $allowed = false;
                }
            } catch ( \Throwable $e ) {
                $cutoff_label = '';
            }
        }

        if ( in_array( $status, [ 'cancelled', 'rejected', 'completed', 'no_show' ], true ) ) {
            $summary = $this->get_terminal_action_message( $status, 'cancel' );
            $why     = __( 'Only active bookings can be cancelled from the dashboard.', 'mybookaro' );
            $next    = __( 'Review the booking timeline if you need a record of what happened.', 'mybookaro' );
        } elseif ( $window_applies && ! $allowed ) {
            $summary = sprintf(
                /* translators: %s: cutoff window hours label. */
                __( 'This booking can no longer be cancelled because the %s cancellation window has passed.', 'mybookaro' ),
                BookingPolicyPresenter::format_window_hours_label( $window_hours )
            );
            $why  = __( 'Once the cancellation window closes, self-service cancellation is locked for this booking.', 'mybookaro' );
            $next = __( 'Contact the other party or a site admin if this booking still needs manual help.', 'mybookaro' );
        } elseif ( $window_applies && $cutoff_label !== '' ) {
            $summary = sprintf(
                /* translators: %s: cancellation cutoff timestamp. */
                __( 'You can cancel this booking until %s.', 'mybookaro' ),
                $cutoff_label
            );
            $why  = __( 'After that time, the cancellation window closes to avoid last-minute changes.', 'mybookaro' );
            $next = __( 'Add a short reason so the other party knows why the booking will not go ahead.', 'mybookaro' );
        } elseif ( $window_hours > 0 && ! $window_applies && $actor_role === 'provider' ) {
            $summary = __( 'No cancellation cutoff applies to providers on this site.', 'mybookaro' );
            $why  = __( 'The customer cancellation window is enabled, but providers are still allowed to handle late changes.', 'mybookaro' );
            $next = __( 'Add a short reason so the customer understands what changed.', 'mybookaro' );
        } else {
            $summary = __( 'No cancellation cutoff is configured for this booking.', 'mybookaro' );
            $why  = __( 'This site does not enforce a time limit for self-service cancellation.', 'mybookaro' );
            $next = __( 'Add a short reason so the other party knows why the booking will not go ahead.', 'mybookaro' );
        }

        return [
            'allowed'        => $allowed,
            'window_hours'   => $window_hours,
            'window_applies' => $window_applies,
            'window_label'   => $window_hours > 0 ? BookingPolicyPresenter::format_window_hours_label( $window_hours ) : '',
            'cutoff_label'   => $cutoff_label,
            'summary'        => $summary,
            'detail_summary' => $summary,
            'why'            => $why,
            'next'           => $next,
            'policy_text'    => BookingPolicyPresenter::build_policy_text( 'cancel', $window_hours, $custom_template, $window_applies ),
        ];
    }

    private function build_reschedule_policy_snapshot( $booking, string $status ): array
    {
        $window_hours    = absint( bookaro_get_option( 'booking_reschedule_window_hours', 0 ) );
        $window_applies  = $window_hours > 0;
        $custom_template = (string) bookaro_get_option( 'booking_reschedule_policy_text', '' );
        $status          = BookingPolicyPresenter::normalize_status( $status );
        $start_raw       = (string) $this->get_booking_value( $booking, 'booking_date', '' );
        $cutoff_label    = '';
        $allowed         = ! in_array( $status, [ 'cancelled', 'rejected', 'completed', 'no_show' ], true );

        if ( $allowed && $window_applies && $start_raw !== '' ) {
            try {
                $tz       = wp_timezone();
                $start_dt = new \DateTime( $start_raw, $tz );
                $cutoff   = ( clone $start_dt )->modify( '-' . $window_hours . ' hours' );
                $now      = new \DateTime( 'now', $tz );
                $cutoff_label = BookingPolicyPresenter::format_datetime_label( $cutoff->format( 'Y-m-d H:i:s' ) );
                if ( $now > $cutoff ) {
                    $allowed = false;
                }
            } catch ( \Throwable $e ) {
                $cutoff_label = '';
            }
        }

        if ( in_array( $status, [ 'cancelled', 'rejected', 'completed', 'no_show' ], true ) ) {
            $summary = $this->get_terminal_action_message( $status, 'reschedule' );
            $why     = __( 'Only active bookings can be rescheduled from the dashboard.', 'mybookaro' );
            $next    = __( 'Review the booking timeline if you need a record of what happened.', 'mybookaro' );
        } elseif ( $window_applies && ! $allowed ) {
            $summary = sprintf(
                /* translators: %s: cutoff window hours label. */
                __( 'This booking can no longer be rescheduled because the %s reschedule window has passed.', 'mybookaro' ),
                BookingPolicyPresenter::format_window_hours_label( $window_hours )
            );
            $why  = __( 'Once the reschedule window closes, self-service schedule changes are locked for this booking.', 'mybookaro' );
            $next = __( 'If the appointment still needs manual changes, contact the provider or a site admin.', 'mybookaro' );
        } elseif ( $window_applies && $cutoff_label !== '' ) {
            $summary = sprintf(
                /* translators: %s: reschedule cutoff timestamp. */
                __( 'You can request a new time until %s.', 'mybookaro' ),
                $cutoff_label
            );
            $why  = __( 'After that time, the reschedule window closes to protect last-minute availability.', 'mybookaro' );
            $next = __( 'Choose another available slot that also respects the site\'s advance-booking rules.', 'mybookaro' );
        } else {
            $summary = __( 'No reschedule cutoff is configured for this booking.', 'mybookaro' );
            $why  = __( 'This site does not enforce a time limit for self-service rescheduling.', 'mybookaro' );
            $next = __( 'Choose another available slot that also respects the site\'s advance-booking rules.', 'mybookaro' );
        }

        return [
            'allowed'        => $allowed,
            'window_hours'   => $window_hours,
            'window_applies' => $window_applies,
            'window_label'   => $window_hours > 0 ? BookingPolicyPresenter::format_window_hours_label( $window_hours ) : '',
            'cutoff_label'   => $cutoff_label,
            'summary'        => $summary,
            'detail_summary' => $summary,
            'why'            => $why,
            'next'           => $next,
            'policy_text'    => BookingPolicyPresenter::build_policy_text( 'reschedule', $window_hours, $custom_template, $window_applies ),
        ];
    }

    private function build_booking_policy_snapshot( $booking, int $user_id = 0 ): array
    {
        $status     = BookingPolicyPresenter::normalize_status( (string) $this->get_booking_value( $booking, 'status', '' ) );
        $actor_role = $this->resolve_action_actor_role( $booking, $user_id );

        return [
            'status'         => $status,
            'status_label'   => BookingPolicyPresenter::get_status_label( $status ),
            'status_message' => $this->build_policy_status_message( $status ),
            'cancel'         => $this->build_cancel_policy_snapshot( $booking, $status, $actor_role ),
            'reschedule'     => $this->build_reschedule_policy_snapshot( $booking, $status ),
        ];
    }

    private function build_action_feedback_payload( string $action, $original_booking, $current_booking, int $user_id, array $context = [] ): array
    {
        $action        = BookingPolicyPresenter::normalize_action( $action );
        $booking       = $current_booking ?: $original_booking;
        $booking_id    = (int) $this->get_booking_value( $booking, 'id', 0 );
        $status        = (string) $this->get_booking_value( $booking, 'status', '' );
        $status_label  = BookingPolicyPresenter::get_status_label( $status );
        $actor_role    = $this->resolve_action_actor_role( $original_booking, $user_id );
        $payment_summary = (string) apply_filters(
            'bookaro_booking_action_payment_summary',
            '',
            $action,
            $booking_id,
            $booking,
            [
                'action'      => $action,
                'actor_id'    => $user_id,
                'actor_role'  => $actor_role,
                'old_status'  => (string) $this->get_booking_value( $original_booking, 'status', '' ),
                'new_status'  => $status,
                'reason'      => (string) ( $context['reason'] ?? '' ),
                'old_date'    => (string) $this->get_booking_value( $original_booking, 'booking_date', '' ),
                'old_date_to' => (string) $this->get_booking_value( $original_booking, 'booking_date_to', '' ),
                'new_date'    => (string) $this->get_booking_value( $booking, 'booking_date', '' ),
                'new_date_to' => (string) $this->get_booking_value( $booking, 'booking_date_to', '' ),
            ]
        );

        if ( $action === 'cancel' ) {
            $title   = __( 'Booking cancelled', 'mybookaro' );
            $summary = __( 'This booking is now marked as cancelled.', 'mybookaro' );
            $message = __( 'Booking cancelled. This booking is now marked as cancelled.', 'mybookaro' );
            $details = [
                $status_label !== '' ? sprintf(
                    /* translators: %s: booking status label. */
                    __( 'Current status: %s.', 'mybookaro' ),
                    $status_label
                ) : '',
            ];
            $reason = trim( (string) ( $context['reason'] ?? '' ) );
            if ( $reason !== '' ) {
                $details[] = sprintf(
                    /* translators: %s: cancellation reason. */
                    __( 'Cancellation reason saved: %s', 'mybookaro' ),
                    $reason
                );
            }
        } else {
            $title          = __( 'Booking rescheduled', 'mybookaro' );
            $new_date_range = BookingPolicyPresenter::format_date_range_label(
                (string) $this->get_booking_value( $booking, 'booking_date', '' ),
                (string) $this->get_booking_value( $booking, 'booking_date_to', '' )
            );
            $old_date_range = BookingPolicyPresenter::format_date_range_label(
                (string) $this->get_booking_value( $original_booking, 'booking_date', '' ),
                (string) $this->get_booking_value( $original_booking, 'booking_date_to', '' )
            );
            $summary = $new_date_range !== ''
                ? sprintf(
                    /* translators: %s: updated booking date range. */
                    __( 'New time: %s.', 'mybookaro' ),
                    $new_date_range
                )
                : __( 'The booking time was updated.', 'mybookaro' );
            $message = __( 'Booking rescheduled.', 'mybookaro' ) . ' ' . $summary;
            $details = [
                $old_date_range !== '' ? sprintf(
                    /* translators: %s: previous booking date range. */
                    __( 'Previous time: %s.', 'mybookaro' ),
                    $old_date_range
                ) : '',
                $status_label !== '' ? sprintf(
                    /* translators: %s: booking status label. */
                    __( 'Booking status remains %s.', 'mybookaro' ),
                    $status_label
                ) : '',
            ];
        }

        return [
            'message'              => trim( $message ),
            'booking_id'           => $booking_id,
            'action'               => $action === 'cancel' ? 'cancelled' : 'rescheduled',
            'booking_status'       => $status,
            'booking_status_label' => $status_label,
            'policy_snapshot'      => $this->build_booking_policy_snapshot( $booking, $user_id ),
            'outcome'              => [
                'title'           => $title,
                'summary'         => $summary,
                'details'         => array_values( array_filter( $details ) ),
                'payment_summary' => trim( $payment_summary ),
            ],
        ];
    }

    private function clear_booking_automation_events( int $booking_id, bool $clear_reminders = true, bool $clear_review_invite = false ): void
    {
        $booking_id = absint( $booking_id );
        if ( $booking_id < 1 ) {
            return;
        }

        if ( $clear_reminders ) {
            wp_clear_scheduled_hook( 'bookaro_send_reminder', [ $booking_id, 'reminder_24h' ] );
            wp_clear_scheduled_hook( 'bookaro_send_reminder', [ $booking_id, 'reminder_1h' ] );
        }

        if ( $clear_review_invite ) {
            wp_clear_scheduled_hook( 'bookaro_send_review_invite', [ $booking_id ] );
        }
    }

    private function schedule_booking_reminders( int $booking_id, string $booking_date ): void
    {
        if ( ! bookaro_get_option( 'notify_reminder', 0 ) ) {
            return;
        }

        if ( trim( $booking_date ) === '' ) {
            return;
        }

        $booking_id = absint( $booking_id );
        if ( $booking_id < 1 ) {
            return;
        }

        try {
            $tz = wp_timezone();
            $start_dt = new \DateTime( $booking_date, $tz );
            $start_ts = $start_dt->getTimestamp();
            // Bug 32: getTimestamp() returns true UTC. current_time('timestamp')
            // returns WP-localized (= UTC + gmt_offset), so the "is the reminder
            // in the future?" comparison below was off by gmt_offset, and
            // wp_schedule_single_event itself expects UTC. Use time() so both
            // sides of the comparison + the API call agree on UTC.
            $now_ts = time();
            $t24 = $start_ts - DAY_IN_SECONDS;
            $t1  = $start_ts - HOUR_IN_SECONDS;

            $this->clear_booking_automation_events( $booking_id, true, false );
            if ( $t24 > $now_ts ) {
                wp_schedule_single_event( $t24, 'bookaro_send_reminder', [ $booking_id, 'reminder_24h' ] );
            }
            if ( $t1 > $now_ts ) {
                wp_schedule_single_event( $t1, 'bookaro_send_reminder', [ $booking_id, 'reminder_1h' ] );
            }
        } catch ( \Throwable $e ) {
            // Keep status transitions non-blocking if scheduling fails.
        }
    }

    private function schedule_booking_review_invite( int $booking_id ): void
    {
        if ( ! bookaro_get_option( 'notify_review_invite', 0 ) ) {
            return;
        }

        $booking_id = absint( $booking_id );
        if ( $booking_id < 1 ) {
            return;
        }

        $review_settings = bookaro_get_review_settings();
        $delay = absint( $review_settings['bookaro_review_invite_delay'] ?? 1 );
        // Bug 32: wp_schedule_single_event() expects a UTC timestamp.
        // current_time('timestamp') returns WP-localized (UTC + gmt_offset),
        // so the review invite was firing gmt_offset hours late on +TZ sites
        // and gmt_offset hours early on -TZ sites. Use time() for true UTC.
        $when  = time() + DAY_IN_SECONDS * $delay;

        $this->clear_booking_automation_events( $booking_id, false, true );
        wp_schedule_single_event( $when, 'bookaro_send_review_invite', [ $booking_id ] );
    }

    private function resolve_status_change_action( string $new_status ): string
    {
        $new_status = sanitize_key( $new_status );
        $map = [
            'approved'  => 'approve',
            'rejected'  => 'reject',
            'completed' => 'complete',
            'no_show'   => 'no_show',
            'cancelled' => 'cancel',
        ];

        return $map[ $new_status ] ?? $new_status;
    }

    private function build_status_change_payment_summary(
        int $booking_id,
        $booking,
        string $old_status,
        string $new_status,
        int $actor_id,
        string $actor_role
    ): string {
        $action = $this->resolve_status_change_action( $new_status );
        $booking_id = absint( $booking_id );
        if ( $booking_id < 1 || $action === '' ) {
            return '';
        }

        return trim( (string) apply_filters(
            'bookaro_booking_action_payment_summary',
            '',
            $action,
            $booking_id,
            $booking,
            [
                'action'      => $action,
                'actor_id'    => $actor_id,
                'actor_role'  => $actor_role,
                'old_status'  => $old_status,
                'new_status'  => $new_status,
                'reason'      => (string) ( $booking->cancel_reason ?? '' ),
                'old_date'    => (string) ( $booking->booking_date ?? '' ),
                'old_date_to' => (string) ( $booking->booking_date_to ?? '' ),
                'new_date'    => (string) ( $booking->booking_date ?? '' ),
                'new_date_to' => (string) ( $booking->booking_date_to ?? '' ),
            ]
        ) );
    }

    private function acquire_booking_lock( int $provider_id ): bool|\WP_Error
    {
        $provider_id = absint( $provider_id );
        if ( $provider_id < 1 ) {
            return true;
        }

        if ( ! $this->is_lock_supported() ) {
            Logger::log( 'booking_lock_unsupported', [ 'provider_id' => $provider_id ] );
            return true;
        }

        global $wpdb;
        $lock_key = 'bookaro_booking_' . $provider_id;
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $locked = $wpdb->get_var(
            $wpdb->prepare(
                'SELECT GET_LOCK(%s, %d)',
                $lock_key,
                5
            )
        );

        if ( (int) $locked !== 1 ) {
            return new \WP_Error(
                'bookaro_booking_lock_timeout',
                __( 'Booking is currently being processed. Please try again.', 'mybookaro' )
            );
        }

        return true;
    }

    private function release_booking_lock( int $provider_id ): void
    {
        $provider_id = absint( $provider_id );
        if ( $provider_id < 1 ) {
            return;
        }

        if ( ! $this->is_lock_supported() ) {
            return;
        }

        global $wpdb;
        $lock_key = 'bookaro_booking_' . $provider_id;
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $wpdb->get_var(
            $wpdb->prepare(
                'SELECT RELEASE_LOCK(%s)',
                $lock_key
            )
        );
    }

    private function is_lock_supported(): bool
    {
        if ( self::$lock_supported !== null ) {
            return self::$lock_supported;
        }

        global $wpdb;
        $lock_key = 'bookaro_lock_test';
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $locked = $wpdb->get_var(
            $wpdb->prepare(
                'SELECT GET_LOCK(%s, %d)',
                $lock_key,
                0
            )
        );

        // Bug 34: the original `if ( (int)$locked !== 1 )` short-circuited
        // for BOTH "MySQL doesn't support GET_LOCK" (returns NULL) AND
        // "another connection holds the test key right now" (returns 0).
        // The second case actually proves locks ARE working — but the old
        // code permanently flagged this request as no-locks, which made
        // acquire_booking_lock return true without acquiring. Under
        // concurrent first-requests across PHP workers (fpm / fastcgi)
        // the test key gets hit by many workers at once; the first one
        // wins, the rest see 0 and skip locking, and the real
        // per-provider booking lock gets bypassed too — leaving
        // createBooking racing on slot reservation.
        //
        // Treat the three return values precisely:
        //   - NULL  → real error, locks unsupported, cache false.
        //   - 0     → contention on the test key; SOMEONE has it, which
        //             means locks DO work. Cache true. Don't release —
        //             we don't hold it.
        //   - 1     → we acquired, release, cache true.
        if ( null === $locked ) {
            self::$lock_supported = false;
            return false;
        }
        if ( (int) $locked === 0 ) {
            self::$lock_supported = true;
            return true;
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $wpdb->get_var(
            $wpdb->prepare(
                'SELECT RELEASE_LOCK(%s)',
                $lock_key
            )
        );

        self::$lock_supported = true;
        return true;
    }

    private function is_single_business_site(): bool
    {
        return function_exists( 'bookaro_is_single_business_site' ) && bookaro_is_single_business_site();
    }

    private function get_resource_label_singular(): string
    {
        if ( function_exists( 'bookaro_get_resource_label_singular' ) ) {
            $label = sanitize_text_field( (string) bookaro_get_resource_label_singular() );
            if ( '' !== $label ) {
                return $label;
            }
        }

        return __( 'Listing', 'mybookaro' );
    }

    private function get_resource_label_singular_lower(): string
    {
        if ( function_exists( 'bookaro_get_resource_label_singular_lower' ) ) {
            $label = sanitize_text_field( (string) bookaro_get_resource_label_singular_lower() );
            if ( '' !== $label ) {
                return $label;
            }
        }

        $label = $this->get_resource_label_singular();
        if ( function_exists( 'bookaro_resource_label_to_lower' ) ) {
            $lower = bookaro_resource_label_to_lower( $label );
            if ( is_string( $lower ) && '' !== trim( $lower ) ) {
                return trim( $lower );
            }
        }

        return strtolower( $label );
    }

    private function get_provider_display_name( int $provider_id ): string
    {
        $provider_id = absint( $provider_id );
        if ( $provider_id < 1 ) {
            return '';
        }

        $provider = get_user_by( 'ID', $provider_id );
        if ( ! $provider || ! isset( $provider->display_name ) ) {
            return '';
        }

        return sanitize_text_field( (string) $provider->display_name );
    }

    /**
     * Resolve display-safe listing fields for booking payloads.
     *
     * @return array{title:string,url:string,image:string}
     */
    private function get_listing_display_data( int $listing_id, int $provider_id = 0 ): array
    {
        $listing_id = absint( $listing_id );
        $provider_id = absint( $provider_id );

        $title = '';
        $url = '';
        $image = '';

        if ( $listing_id > 0 ) {
            $title = sanitize_text_field( (string) get_the_title( $listing_id ) );
            $permalink = get_permalink( $listing_id );
            $url = is_string( $permalink ) ? $permalink : '';
            $thumbnail = get_the_post_thumbnail_url( $listing_id );
            $image = is_string( $thumbnail ) ? $thumbnail : '';
        }

        if ( '' === $title ) {
            $provider_name = $this->get_provider_display_name( $provider_id );
            if ( '' !== $provider_name ) {
                $title = $provider_name;
            } elseif ( $listing_id > 0 ) {
                $title = sprintf(
                    /* translators: 1: resource label singular, 2: listing id */
                    __( '%1$s #%2$d', 'mybookaro' ),
                    $this->get_resource_label_singular(),
                    $listing_id
                );
            } else {
                $title = $this->get_resource_label_singular();
            }
        }

        return [
            'title' => $title,
            'url' => $url,
            'image' => $image,
        ];
    }

    private function normalize_service_label_key( string $label ): string
    {
        if ( function_exists( 'bookaro_normalize_service_label' ) ) {
            return bookaro_normalize_service_label( $label );
        }

        return strtolower( trim( $label ) );
    }

    private function get_provider_services_for_context( int $provider_id ): array
    {
        if ( $provider_id < 1 ) {
            return [];
        }

        $services = function_exists( 'bookaro_get_bookaro_provider_services_for_provider' )
            ? bookaro_get_bookaro_provider_services_for_provider( $provider_id )
            : [];

        if ( ! $this->is_single_business_site() ) {
            return is_array( $services ) ? $services : [];
        }

        $query = new \WP_Query( [
            'post_type'              => BOOKARO_SERVICE_POST_TYPE,
            'post_status'            => 'publish',
            'posts_per_page'         => -1,
            'orderby'                => [
                'menu_order' => 'ASC',
                'title'      => 'ASC',
                'ID'         => 'ASC',
            ],
            // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
            'meta_query'             => [
                [
                    'key'     => BOOKARO_SERVICE_PROVIDER_META,
                    'value'   => (string) $provider_id,
                    'compare' => '=',
                    'type'    => 'NUMERIC',
                ],
                [
                    'key'     => BOOKARO_SERVICE_OWNER_META,
                    'value'   => 'provider',
                    'compare' => '=',
                ],
            ],
            'no_found_rows'          => true,
            'update_post_term_cache' => false,
        ] );

        $extra = [];
        foreach ( $query->posts as $post ) {
            if ( ! $post instanceof \WP_Post ) {
                continue;
            }
            $listing_id = absint( get_post_meta( $post->ID, BOOKARO_SERVICE_LISTING_META, true ) );
            if ( $listing_id > 0 ) {
                if ( function_exists( 'bookaro_is_allowed_listing' ) && ! bookaro_is_allowed_listing( $listing_id ) ) {
                    continue;
                }
                if ( function_exists( 'bookaro_is_lite_listing_allowed' ) && ! bookaro_is_lite_listing_allowed( $listing_id ) ) {
                    continue;
                }
            }
            if ( function_exists( 'bookaro_build_service_payload' ) ) {
                $extra[] = bookaro_build_service_payload( $post, $listing_id );
            }
        }

        $merged = array_merge(
            is_array( $services ) ? $services : [],
            $extra
        );
        if ( function_exists( 'bookaro_dedupe_services_by_label' ) ) {
            return bookaro_dedupe_services_by_label( $merged );
        }

        return $merged;
    }

    private function get_generic_provider_context_catalog( int $provider_id ): array
    {
        $model = function_exists( 'bookaro_get_services_model' )
            ? sanitize_key( (string) bookaro_get_services_model() )
            : 'provider_only';
        $allow_custom = function_exists( 'bookaro_allow_provider_custom_services' )
            ? (bool) bookaro_allow_provider_custom_services()
            : true;
        $allow_override = function_exists( 'bookaro_allow_provider_override_catalog' )
            ? (bool) bookaro_allow_provider_override_catalog()
            : true;

        $catalog = function_exists( 'bookaro_get_bookaro_global_services' )
            ? bookaro_get_bookaro_global_services( 0 )
            : [];
        $provider_services = $this->get_provider_services_for_context( $provider_id );

        if ( 'admin_only' === $model || 'admin_catalog' === $model ) {
            return $catalog;
        }
        if ( 'provider_only' === $model ) {
            return $provider_services;
        }

        $services = [];
        if ( ! $allow_custom && ! $allow_override ) {
            $services = $catalog;
        } else {
            if ( ! $allow_custom ) {
                $catalog_keys = [];
                foreach ( $catalog as $service ) {
                    if ( ! is_array( $service ) ) {
                        continue;
                    }
                    $label = (string) ( $service['title'] ?? '' );
                    $key = $this->normalize_service_label_key( $label );
                    if ( '' !== $key ) {
                        $catalog_keys[ $key ] = true;
                    }
                }
                $provider_services = array_values( array_filter(
                    $provider_services,
                    function ( $service ) use ( $catalog_keys ): bool {
                        if ( ! is_array( $service ) || empty( $catalog_keys ) ) {
                            return false;
                        }
                        $label = (string) ( $service['title'] ?? '' );
                        $key = $this->normalize_service_label_key( $label );
                        return '' !== $key && isset( $catalog_keys[ $key ] );
                    }
                ) );
            }

            if ( $allow_override ) {
                $services = array_merge( $provider_services, $catalog );
            } else {
                $services = array_merge( $catalog, $provider_services );
            }
        }

        if ( function_exists( 'bookaro_dedupe_services_by_label' ) ) {
            return bookaro_dedupe_services_by_label( $services );
        }

        return $services;
    }

    /**
     * Insert a booking based on sanitized request data.
     *
     * @return bool|\WP_Error
     */
    public function createBooking(array $data)
    {
        $lock_provider_id = 0;
        $lock_acquired = false;
        try {
            $listing_id    = absint($data['listing_id'] ?? 0);
            $provider_id   = absint($data['provider_id'] ?? 0);
            // Lite always resolves availability at provider level.
            $service_id    = absint($data['service_id'] ?? 0);
            $service_label = sanitize_text_field($data['service_label'] ?? '');
            $service_token = sanitize_text_field($data['service_token'] ?? '');
            $service_duration = absint($data['service_duration'] ?? 0);
            $date_raw      = sanitize_text_field($data['booking_date'] ?? '');
            $date_to_raw   = sanitize_text_field($data['booking_date_to'] ?? '');
            $time_raw      = sanitize_text_field($data['time'] ?? '');
            $name          = sanitize_text_field($data['name'] ?? '');
            $email         = sanitize_email($data['email'] ?? '');
            $phone         = sanitize_text_field($data['phone'] ?? '');
            $price         = floatval($data['price'] ?? 0);
            $integration_mode = bookaro_get_integration_mode();
            $single_business = $this->is_single_business_site();
            $resource_label = $this->get_resource_label_singular();
            $resource_label_lower = $this->get_resource_label_singular_lower();
            $listing_post = null;
            if ( $listing_id < 1 ) {
                if ( ! $single_business ) {
                    return new \WP_Error(
                        'bookaro_listing_required',
                        sprintf(
                            /* translators: %s: resource label singular */
                            __( '%s is required for this booking flow. Please refresh the page and try again.', 'mybookaro' ),
                            $resource_label
                        )
                    );
                }
                if ( $provider_id < 1 || ! get_user_by( 'ID', $provider_id ) ) {
                    return new \WP_Error(
                        'bookaro_provider_missing',
                        __( 'Provider is required. Please refresh the page and try again.', 'mybookaro' )
                    );
                }
            } else {
                $listing_post = get_post( $listing_id );
                if ( ! $listing_post ) {
                    return new \WP_Error(
                        'bookaro_listing_missing',
                        sprintf(
                            /* translators: %s: resource label singular */
                            __( '%s not found. Please refresh the page and try again.', 'mybookaro' ),
                            $resource_label
                        )
                    );
                }
                if ( 'publish' !== $listing_post->post_status ) {
                    return new \WP_Error(
                        'bookaro_listing_unpublished',
                        sprintf(
                            /* translators: 1: resource label singular, 2: lowercase resource label singular */
                            __( '%1$s is not published. Ask the provider to publish the %2$s.', 'mybookaro' ),
                            $resource_label,
                            $resource_label_lower
                        )
                    );
                }
                if ( ! bookaro_is_allowed_listing( $listing_id ) ) {
                    return new \WP_Error(
                        'bookaro_listing_not_allowed',
                        sprintf(
                            /* translators: %s: lowercase resource label singular */
                            __( 'This %1$s cannot accept bookings. Ask the provider to enable bookings for this %1$s.', 'mybookaro' ),
                            $resource_label_lower
                        )
                    );
                }
                $listing_owner_id = function_exists( 'bookaro_get_listing_owner_id' )
                    ? (int) bookaro_get_listing_owner_id( $listing_id )
                    : (int) $listing_post->post_author;
                if ( $listing_owner_id > 0 ) {
                    $provider_id = $listing_owner_id;
                }
                if ( $provider_id < 1 ) {
                    return new \WP_Error(
                        'bookaro_provider_missing',
                        sprintf(
                            /* translators: %s: lowercase resource label singular */
                            __( 'This %s has no provider assigned. Please contact the site admin.', 'mybookaro' ),
                            $resource_label_lower
                        )
                    );
                }
            }

            if ( $service_id ) {
                $service_post = get_post( $service_id );
                if ( ! $service_post || $service_post->post_type !== BOOKARO_SERVICE_POST_TYPE || 'publish' !== $service_post->post_status ) {
                    return new \WP_Error(
                        'bookaro_service_invalid',
                        __( 'Selected service is not available. Please choose another service.', 'mybookaro' )
                    );
                }
                $service_listing_id = (int) get_post_meta( $service_id, BOOKARO_SERVICE_LISTING_META, true );
                $service_provider_id = (int) get_post_meta( $service_id, BOOKARO_SERVICE_PROVIDER_META, true );
                $service_owner_type = sanitize_key( (string) get_post_meta( $service_id, BOOKARO_SERVICE_OWNER_META, true ) );
                $allow_global_service = false;
                if ( 'generic' === $integration_mode && function_exists( 'bookaro_get_generic_services_mode' ) ) {
                    $services_mode = bookaro_get_generic_services_mode();
                    $allow_global_service = in_array( $services_mode, [ 'admin_catalog', 'hybrid', 'admin_only' ], true );
                }
                $listing_match = $service_listing_id === $listing_id && $listing_id > 0;
                $global_match = $allow_global_service && $service_listing_id === 0;
                $provider_match = false;
                if ( $provider_id > 0 && $listing_id < 1 ) {
                    if ( $service_provider_id > 0 ) {
                        $provider_match = ( $service_provider_id === $provider_id );
                    } elseif ( $service_listing_id > 0 ) {
                        $service_listing_provider_id = function_exists( 'bookaro_get_listing_owner_id' )
                            ? (int) bookaro_get_listing_owner_id( $service_listing_id )
                            : 0;
                        if ( $service_listing_provider_id < 1 ) {
                            $service_listing = get_post( $service_listing_id );
                            if ( $service_listing ) {
                                $service_listing_provider_id = (int) $service_listing->post_author;
                            }
                        }
                        $provider_match = ( $service_listing_provider_id > 0 && $service_listing_provider_id === $provider_id );
                    } elseif ( 'provider' === $service_owner_type ) {
                        $provider_match = ( (int) $service_post->post_author === $provider_id );
                    }
                }
                if ( ! $listing_match && ! $global_match && ! $provider_match ) {
                    $mismatch_message = $listing_id > 0
                        ? sprintf(
                            /* translators: %s: lowercase resource label singular */
                            __( 'Selected service does not belong to this %s. Please choose another service.', 'mybookaro' ),
                            $resource_label_lower
                        )
                        : __( 'Selected service is not available for this provider. Please choose another service.', 'mybookaro' );
                    return new \WP_Error(
                        'bookaro_service_mismatch',
                        $mismatch_message
                    );
                }
                $service_label = sanitize_text_field(
                    function_exists( 'bookaro_get_service_label' )
                        ? bookaro_get_service_label( $service_post )
                        : $service_post->post_title
                );
                $price = (float) get_post_meta( $service_id, BOOKARO_SERVICE_PRICE_META, true );
                $service_duration = function_exists( 'bookaro_get_service_duration_minutes' )
                    ? bookaro_get_service_duration_minutes( $service_id )
                    : (int) get_post_meta( $service_id, BOOKARO_SERVICE_DURATION_META, true );
            } elseif ( 'generic' !== $integration_mode ) {
                $resolved_services = function_exists( 'bookaro_resolve_listing_services' )
                    ? bookaro_resolve_listing_services( $listing_id, $integration_mode )
                    : [];
                $match = null;
                if ( '' !== $service_token && is_array( $resolved_services ) ) {
                    foreach ( $resolved_services as $service ) {
                        $token = sanitize_text_field( $service['token'] ?? '' );
                        if ( '' === $token ) {
                            continue;
                        }
                        if ( $token === $service_token ) {
                            $match = $service;
                            break;
                        }
                    }
                }
                if ( ! $match ) {
                    $label_key = strtolower( trim( $service_label ) );
                    if ( '' === $label_key ) {
                        return new \WP_Error(
                            'bookaro_service_required',
                            __( 'Service is required.', 'mybookaro' )
                        );
                    }
                    if ( is_array( $resolved_services ) ) {
                        foreach ( $resolved_services as $service ) {
                            $title = sanitize_text_field( $service['title'] ?? '' );
                            if ( '' === $title ) {
                                continue;
                            }
                            if ( strtolower( trim( $title ) ) === $label_key ) {
                                $match = $service;
                                break;
                            }
                        }
                    }
                }
                if ( ! $match ) {
                    $message = $listing_id > 0
                        ? sprintf(
                            /* translators: %s: lowercase resource label singular */
                            __( 'Service not found. Please choose a valid service for this %s.', 'mybookaro' ),
                            $resource_label_lower
                        )
                        : __( 'Service not found. Please choose a valid service for this provider.', 'mybookaro' );
                    return new \WP_Error(
                        'bookaro_service_not_found',
                        $message
                    );
                }
                $service_label = sanitize_text_field( $match['title'] ?? $service_label );
                $price = isset( $match['price'] ) ? (float) $match['price'] : 0.0;
                $service_duration = isset( $match['duration'] ) ? absint( $match['duration'] ) : 0;
            } elseif ( 'generic' === $integration_mode ) {
                if ( '' === trim( $service_label ) ) {
                    return new \WP_Error(
                        'bookaro_service_required',
                        __( 'Service is required.', 'mybookaro' )
                    );
                }
                if ( $listing_id < 1 ) {
                    $needle = function_exists( 'bookaro_normalize_service_label' )
                        ? bookaro_normalize_service_label( $service_label )
                        : strtolower( trim( $service_label ) );
                    if ( '' === $needle ) {
                        return new \WP_Error(
                            'bookaro_service_required',
                            __( 'Service is required.', 'mybookaro' )
                        );
                    }

                    $catalog = $this->get_generic_provider_context_catalog( $provider_id );
                    $match = null;
                    if ( is_array( $catalog ) ) {
                        foreach ( $catalog as $service ) {
                            if ( ! is_array( $service ) ) {
                                continue;
                            }
                            $label = (string) ( $service['title'] ?? '' );
                            $key = function_exists( 'bookaro_normalize_service_label' )
                                ? bookaro_normalize_service_label( $label )
                                : strtolower( trim( $label ) );
                            if ( $key !== '' && $key === $needle ) {
                                $match = $service;
                                break;
                            }
                        }
                    }
                    if ( ! $match ) {
                        return new \WP_Error(
                            'bookaro_service_not_found',
                            __( 'Service not found. Please choose a valid service for this provider.', 'mybookaro' )
                        );
                    }

                    $service_id = absint( $match['id'] ?? 0 );
                    $service_label = sanitize_text_field( (string) ( $match['title'] ?? $service_label ) );
                    $price = (float) ( $match['price'] ?? 0 );
                    $service_duration = absint( $match['duration'] ?? 0 );
                } else {
                    $needle = function_exists( 'bookaro_normalize_service_label' )
                        ? bookaro_normalize_service_label( $service_label )
                        : strtolower( trim( $service_label ) );
                    if ( '' === $needle ) {
                        return new \WP_Error(
                            'bookaro_service_required',
                            __( 'Service is required.', 'mybookaro' )
                        );
                    }

                    $catalog = function_exists( 'bookaro_get_services_catalog_for_listing' )
                        ? bookaro_get_services_catalog_for_listing( $listing_id )
                        : [];
                    $match = null;
                    if ( is_array( $catalog ) ) {
                        foreach ( $catalog as $service ) {
                            if ( ! is_array( $service ) ) {
                                continue;
                            }
                            $label = (string) ( $service['title'] ?? '' );
                            $key = function_exists( 'bookaro_normalize_service_label' )
                                ? bookaro_normalize_service_label( $label )
                                : strtolower( trim( $label ) );
                            if ( $key !== '' && $key === $needle ) {
                                $match = $service;
                                break;
                            }
                        }
                    }
                    if ( ! $match ) {
                        return new \WP_Error(
                            'bookaro_service_not_found',
                            sprintf(
                                /* translators: %s: lowercase resource label singular */
                                __( 'Service not found. Please choose a valid service for this %s.', 'mybookaro' ),
                                $resource_label_lower
                            )
                        );
                    }

                    $service_id = absint( $match['id'] ?? 0 );
                    $service_label = sanitize_text_field( (string) ( $match['title'] ?? $service_label ) );
                    $price = (float) ( $match['price'] ?? 0 );
                    $service_duration = absint( $match['duration'] ?? 0 );
                }
            }

            if ( '' === $date_raw ) {
                return new \WP_Error(
                    'bookaro_date_required',
                    __( 'Please select a booking date.', 'mybookaro' )
                );
            }

            $date_time_str = $time_raw ? sprintf('%s %s', $date_raw, $time_raw) : $date_raw;
            $dt =   \DateTime::createFromFormat('Y-m-d H:i:s', $date_time_str)
                ?: \DateTime::createFromFormat('Y-m-d H:i', $date_time_str)
                ?: \DateTime::createFromFormat('Y-m-d', $date_time_str);
            if (!$dt) {
                return new \WP_Error(
                    'bookaro_invalid_date',
                    __( 'Please select a valid date and time.', 'mybookaro' )
                );
            }

            // Reject bookings whose start datetime is in the past. The customer
            // booking modal already greys out historical dates, so this branch
            // protects against crafted/replayed API calls. The
            // `bookaro_allow_past_date_booking` filter lets server-side flows
            // (e.g., an admin re-entering a walk-in retroactively) opt out.
            $now_ts = (int) current_time( 'timestamp', true );
            $booking_ts = $dt->getTimestamp();
            if ( $booking_ts < $now_ts && ! apply_filters( 'bookaro_allow_past_date_booking', false, $dt, $data ) ) {
                return new \WP_Error(
                    'bookaro_past_date',
                    __( 'You cannot book a date that is in the past. Please choose a future date.', 'mybookaro' )
                );
            }

            $availability_id = $provider_id;
            $is_day_interval = false;
            foreach ($this->schedule->get_timeslots($availability_id) as $slot) {
                if ((int)$slot->interval_m === 1440) {
                    $is_day_interval = true;
                    break;
                }
            }
            if ($is_day_interval) {
                $dt->setTime(0, 0, 0);
            }
            $booking_date = $dt->format('Y-m-d H:i:s');

            $booking_date_to = null;
            if ($date_to_raw) {
                $dt2 = \DateTime::createFromFormat('Y-m-d', $date_to_raw);
                if (!$dt2) {
                    return new \WP_Error(
                        'bookaro_invalid_end_date',
                        __( 'Please select a valid end date.', 'mybookaro' )
                    );
                }
                if ($is_day_interval) {
                    $dt2->setTime(0, 0, 0);
                }
                if ($dt2 < $dt) {
                    return new \WP_Error(
                        'bookaro_invalid_range',
                        __( 'End date must be after the start date.', 'mybookaro' )
                    );
                }
                // Bug 60: cap the range span at 1 year regardless of the
                // booking_max_window_days setting. Without this, an
                // attacker can submit booking_date=2026-01-01 +
                // booking_date_to=2099-12-31 and the row is accepted —
                // subsequent get_booked_slots() expands the range
                // day-by-day, slowing every availability query and
                // visually filling 27,000+ days of calendar. The
                // booking_max_window_days setting also caps this when
                // set, but it defaults to 0 (no cap); we want a hard
                // floor that protects every install.
                $range_days = (int) $dt->diff( $dt2 )->format( '%a' );
                $max_range_days = (int) apply_filters( 'bookaro_max_booking_range_days', 365 );
                if ( $max_range_days > 0 && $range_days > $max_range_days ) {
                    return new \WP_Error(
                        'bookaro_range_too_large',
                        sprintf(
                            /* translators: %d: maximum booking span in days. */
                            __( 'Booking range cannot exceed %d days.', 'mybookaro' ),
                            $max_range_days
                        )
                    );
                }
                $booking_date_to = $dt2->format('Y-m-d H:i:s');
            }

            if ($is_day_interval) {
                $duration_days = $service_duration > 0 ? (int) ceil($service_duration / 1440) : 0;
                if ($duration_days > 0) {
                    $min_end = clone $dt;
                    $min_end->modify('+' . ($duration_days - 1) . ' day');
                    if (empty($booking_date_to) || new \DateTime($booking_date_to) < $min_end) {
                        $booking_date_to = $min_end->format('Y-m-d H:i:s');
                    }
                }
            } elseif ($service_duration > 0) {
                $end_dt = clone $dt;
                $end_dt->modify('+' . $service_duration . ' minutes');
                $booking_date_to = $end_dt->format('Y-m-d H:i:s');
            }

            if ( '' === trim( $service_label ) ) {
                return new \WP_Error(
                    'bookaro_service_required',
                    __( 'Please select a service.', 'mybookaro' )
                );
            }
            if ( '' === trim( $name ) ) {
                return new \WP_Error(
                    'bookaro_name_required',
                    __( 'Please enter your name.', 'mybookaro' )
                );
            }
            if ( '' === trim( $email ) || ! is_email( $email ) ) {
                return new \WP_Error(
                    'bookaro_email_required',
                    __( 'Please enter a valid email address.', 'mybookaro' )
                );
            }
            $phone_required = (bool) bookaro_get_option( 'booking_phone_required', 1 );
            if ( $phone_required && '' === trim( $phone ) ) {
                return new \WP_Error(
                    'bookaro_phone_required',
                    __( 'Please enter a phone number.', 'mybookaro' )
                );
            }

            $tz = wp_timezone();
            $booking_start = new \DateTime( $booking_date, $tz );
            $booking_end = $booking_date_to ? new \DateTime( $booking_date_to, $tz ) : clone $booking_start;

            $min_notice_hours = absint( bookaro_get_option( 'booking_min_notice_hours', 0 ) );
            if ( $min_notice_hours > 0 ) {
                $min_start = ( new \DateTime( 'now', $tz ) )->modify( '+' . $min_notice_hours . ' hours' );
                if ( $booking_start < $min_start ) {
                    return new \WP_Error(
                        'bookaro_min_notice',
                        sprintf(
                            /* translators: %d: minimum notice hours before booking. */
                            __( 'Please book at least %d hours in advance.', 'mybookaro' ),
                            $min_notice_hours
                        )
                    );
                }
            }

            $max_window_days = absint( bookaro_get_option( 'booking_max_window_days', 0 ) );
            if ( $max_window_days > 0 ) {
                $max_end = ( new \DateTime( 'now', $tz ) )
                    ->modify( '+' . $max_window_days . ' days' )
                    ->setTime( 23, 59, 59 );
                if ( $booking_start > $max_end || $booking_end > $max_end ) {
                    return new \WP_Error(
                        'bookaro_max_window',
                        sprintf(
                            /* translators: %d: maximum booking window in days. */
                            __( 'Bookings can be made up to %d days in advance.', 'mybookaro' ),
                            $max_window_days
                        )
                    );
                }
            }

            $lock_provider_id = $availability_id;
            $lock = $this->acquire_booking_lock( $availability_id );
            if ( is_wp_error( $lock ) ) {
                return $lock;
            }
            $lock_acquired = true;

            $availability_duration = $service_duration;
            if ( $is_day_interval && $booking_date_to ) {
                $days = (int) $booking_start->diff( $booking_end )->days + 1;
                $availability_duration = $days * 1440;
            }
            $availability_end = $booking_date_to ? $booking_end->format( 'Y-m-d' ) : $booking_start->format( 'Y-m-d' );
            $available_slots = $this->db->get_available_slots(
                $provider_id,
                $booking_start->format( 'Y-m-d' ),
                $availability_end,
                (int) $availability_duration
            );
            if ( empty( $available_slots ) ) {
                return new \WP_Error(
                    'bookaro_no_availability',
                    __( 'No availability for the selected date. Ask the provider to set availability in Bookings > Settings > Availability.', 'mybookaro' )
                );
            }
            $slot_start = $booking_start->format( 'Y-m-d H:i:s' );
            $slot_ok = false;
            foreach ( $available_slots as $slot ) {
                if ( isset( $slot['start'] ) && $slot['start'] === $slot_start ) {
                    $slot_ok = true;
                    break;
                }
            }
            if ( ! $slot_ok ) {
                return new \WP_Error(
                    'bookaro_unavailable',
                    __( 'Selected time is no longer available. Please choose another slot.', 'mybookaro' )
                );
            }

            global $wpdb;
            $table = $this->db->get_table_name();
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $inserted = $wpdb->insert(
                $table,
                [
                    'listing_id'      => $listing_id,
                    'service_id'      => $service_id,
                    'service_label'   => $service_label,
                    'price'           => $price,
                    'customer_id'    => get_current_user_id(),
                    'customer_phone' => $phone,
                    'provider_id'     => $provider_id,
                    'booking_date'    => $booking_date,
                    'booking_date_to' => $booking_date_to,
                    'status'          => 'pending',
                    // Bug 35: cap before write (matches addNote / cancel_reason).
                    'notes'           => $this->truncate_user_text( sanitize_textarea_field( $data['notes'] ?? '' ) ),
                    'created_at'      => current_time('mysql'),
                    'updated_at'      => current_time('mysql'),
                ]
            );
            if (!$inserted) {
                return new \WP_Error(
                    'bookaro_save_failed',
                    __( 'Failed to save booking. Please try again.', 'mybookaro' )
                );
            }
            $booking_id = (int)$wpdb->insert_id;

            // Bug 30: invalidate the slot transient cache as soon as the
            // booking row exists. get_available_slots caches results for
            // an hour keyed by (provider_id, from, to, duration). Without
            // this flush, a concurrent createBooking inside the same lock
            // session would read the just-populated cache (which still
            // shows our slot as free, because it was computed BEFORE this
            // INSERT) and happily double-book. Same key shape gets reused
            // on availability reads from the customer dashboard, so the
            // cache stay-stale window is also visible to humans browsing
            // immediately after a booking lands.
            $this->db->flush_provider_slot_cache( $provider_id );

            $payload_data = is_array( $data ) ? $data : [];
            unset( $payload_data['staff_id'] );

            $booking_payload = array_merge(
                $payload_data,
                [
                    'booking_id'      => $booking_id,
                    'provider_id'     => $provider_id,
                    'listing_id'      => $listing_id,
                    'service_id'      => $service_id,
                    'service'         => $service_label,
                    'service_label'   => $service_label,
                    'price'           => $price,
                    'customer_name'   => $name,
                    'customer_email'  => $email,
                    'customer_phone'  => $phone,
                    'booking_date'    => $booking_date,
                    'booking_date_to' => $booking_date_to,
                    'date'            => $dt->format( 'Y-m-d' ),
                    'date_to'         => $booking_date_to ? ( new \DateTime( $booking_date_to ) )->format( 'Y-m-d' ) : null,
                ]
            );

            $response = [
                'booking_id' => $booking_id,
            ];

            $booking = $this->db->get_booking( $booking_id );
            $context = [
                'booking_id' => $booking_id,
                'payload'    => $booking_payload,
            ];
            // Lite never creates orders; Pro may enrich response via this filter.
            $response = apply_filters( 'bookaro_booking_created_response', $response, $booking, $context );
            if ( is_wp_error( $response ) ) {
                $this->db->delete_booking( $booking_id );
                return $response;
            }
            if ( ! is_array( $response ) ) {
                $response = [ 'booking_id' => $booking_id ];
            } else {
                $response = array_merge( [ 'booking_id' => $booking_id ], $response );
            }

            $provider = get_user_by('ID', $provider_id);
            $dashboard_url = function_exists( 'bookaro_get_dashboard_url' )
                ? bookaro_get_dashboard_url( [ 'tab' => 'bookings', 'listing_id' => $listing_id ] )
                : site_url( '/my-account/bookings-and-management/?tab=bookings' );

            if ( bookaro_get_option( 'notify_new_booking', 0 ) ) {
                if ( ! $provider ) {
                    Logger::log('provider_missing_for_booking_notification', [
                        'booking_id' => $booking_id,
                        'provider_id' => $provider_id,
                    ]);
                } else {
                    ob_start();
                    $this->emails->sendTemplatedEmail(
                        $provider->user_email,
                        'new_booking',
                        [
                            'provider' => UserHelper::preferred_name($provider),
                            'service'  => $service_label,
                            'date'     => $booking_date,
                            'date_to'  => $booking_date_to,
                            'owner'    => $name,
                            'email'    => $email,
                            'dashboard_url' => $dashboard_url,
                            'manage_booking_url' => $dashboard_url,
                        ],
                        $provider_id,
                        [],
                        $booking_id
                    );
                    ob_end_clean();
                }
            }

            if ( $provider ) {
                $listing_display = $this->get_listing_display_data( $listing_id, $provider_id );
                $replacements = [
                    'provider'     => UserHelper::preferred_name( $provider ),
                    'service'      => $service_label,
                    'date'         => $booking_date,
                    'date_to'      => $booking_date_to,
                    'date_range'   => $this->format_date_range( $booking_date, $booking_date_to ),
                    'owner'        => $name,
                    'email'        => $email,
                    'dashboard_url'=> $dashboard_url,
                    'listing_title'=> (string) ( $listing_display['title'] ?? '' ),
                    'listing_url'  => (string) ( $listing_display['url'] ?? '' ),
                    'customer_phone' => $phone,
                ];
                $context = [
                    'provider_id' => $provider_id,
                    'customer_id' => get_current_user_id(),
                    'listing_id'  => $listing_id,
                    'service_id'  => $service_id,
                    'booking_date' => $booking_date,
                    'booking_date_to' => $booking_date_to,
                    'dashboard_url' => $dashboard_url,
                    'manage_booking_url' => $dashboard_url,
                    'booking' => $booking_payload,
                    'replacements' => $replacements,
                ];
                $this->dispatch_notification_event( 'new_booking', $booking_id, $context );
            }

            do_action( 'bookaro_booking_created', $booking_id, $booking_payload );

            $auto_approve = (bool) bookaro_get_option( 'auto_approve_bookings', 0 );
            if ( $auto_approve ) {
                $this->updateBookingStatus( $booking_id, 'approved' );
                $response['status'] = 'approved';
            } else {
                $response['status'] = 'pending';
            }

            return $response;
        } catch (\Throwable $e) {
            Logger::log('create_booking failed', [
                'exception' => $e->getMessage(),
                'code' => $e->getCode(),
                'trace' => $e->getTraceAsString(),
            ]);
            return new \WP_Error(
                'bookaro_create_failed',
                __( 'Booking failed due to a server error. Please try again.', 'mybookaro' )
            );
        } finally {
            if ( $lock_acquired ) {
                $this->release_booking_lock( $lock_provider_id );
            }
        }
    }

    /**
     * Return available times for a provider/date.
     */
    public function checkAvailability(int $provider_id, string $date, int $duration_minutes = 0): array
    {
        try {
            $slots = $this->db->get_available_slots($provider_id, $date, $date, $duration_minutes, 0);
            return array_map(fn($s) => substr($s['start'], 11, 5), $slots);
        } catch (\Throwable $e) {
            Logger::log('check_availability failed', [
                'exception' => $e->getMessage(),
                'code' => $e->getCode(),
                'trace' => $e->getTraceAsString(),
            ]);
            return [];
        }
    }

    private function get_service_snapshot( int $service_id ): array
    {
        if ( $service_id <= 0 ) {
            return [];
        }
        $service = get_post( $service_id );
        if ( ! $service || $service->post_type !== BOOKARO_SERVICE_POST_TYPE ) {
            return [];
        }

        return [
            'title'    => function_exists( 'bookaro_get_service_label' )
                ? bookaro_get_service_label( $service )
                : $service->post_title,
            'price'    => (float) get_post_meta( $service_id, BOOKARO_SERVICE_PRICE_META, true ),
            'duration' => function_exists( 'bookaro_get_service_duration_minutes' )
                ? bookaro_get_service_duration_minutes( $service_id )
                : (int) get_post_meta( $service_id, BOOKARO_SERVICE_DURATION_META, true ),
        ];
    }

    private function resolve_integration_service_duration( int $listing_id, string $service_label, int $service_id = 0 ): int
    {
        if ( $listing_id < 1 || $service_label === '' ) {
            return 0;
        }

        $duration = (int) apply_filters(
            'bookaro_integration_service_duration',
            0,
            $listing_id,
            $service_label,
            $service_id
        );

        return max( 0, $duration );
    }

    private function get_provider_interval_minutes( int $provider_id, \DateTime $dt ): int
    {
        $weekday = (int) $dt->format( 'w' );
        $time    = $dt->format( 'H:i:s' );
        $slots   = $this->schedule->get_timeslots( $provider_id );

        foreach ( $slots as $slot ) {
            if ( (int) $slot->weekday !== $weekday ) {
                continue;
            }
            $start_time = new \DateTime( $dt->format( 'Y-m-d' ) . ' ' . $slot->start_time );
            $end_time   = new \DateTime( $dt->format( 'Y-m-d' ) . ' ' . $slot->end_time );
            if ( $dt >= $start_time && $dt < $end_time ) {
                return (int) $slot->interval_m;
            }
        }

        if ( ! empty( $slots ) ) {
            return (int) ( $slots[0]->interval_m ?? 30 );
        }

        return 30;
    }

    private function infer_duration_minutes( ?string $booking_date, ?string $booking_date_to, int $provider_id ): int
    {
        if ( empty( $booking_date ) ) {
            return 0;
        }

        $tz = wp_timezone();
        $start = new \DateTime( $booking_date, $tz );
        $is_day_interval = $this->db->is_day_interval( $provider_id );

        if ( ! empty( $booking_date_to ) ) {
            $end = new \DateTime( $booking_date_to, $tz );
            if ( $is_day_interval ) {
                $days = (int) $start->diff( $end )->days + 1;
                return max( 1, $days ) * 1440;
            }
            $diff = (int) round( ( $end->getTimestamp() - $start->getTimestamp() ) / 60 );
            if ( $diff > 0 ) {
                return $diff;
            }
        }

        return max( 0, $this->get_provider_interval_minutes( $provider_id, $start ) );
    }

    private function resolve_booking_duration_minutes(
        int $provider_id,
        int $listing_id,
        string $service_label,
        int $service_id,
        ?string $booking_date,
        ?string $booking_date_to
    ): int {
        $duration = 0;
        if ( $service_id > 0 ) {
            $service_info = $this->get_service_snapshot( $service_id );
            $duration = (int) ( $service_info['duration'] ?? 0 );
        }
        if ( $duration < 1 && $listing_id > 0 && $service_label !== '' ) {
            $duration = $this->resolve_integration_service_duration( $listing_id, $service_label, $service_id );
        }
        if ( $duration < 1 ) {
            $duration = $this->infer_duration_minutes( $booking_date, $booking_date_to, $provider_id );
        }

        return max( 0, $duration );
    }

    private function resolve_listing_location( int $listing_id, int $booking_id = 0 ): string
    {
        if ( $listing_id < 1 ) {
            return '';
        }

        $location = '';
        foreach ( [ 'job_location', 'geolocation_formatted_address', 'listing_location', 'location', 'address' ] as $meta_key ) {
            $value = get_post_meta( $listing_id, $meta_key, true );
            if ( is_string( $value ) && trim( $value ) !== '' ) {
                $location = trim( $value );
                break;
            }
        }

        return (string) apply_filters( 'bookaro_booking_location', $location, $listing_id, $booking_id, null );
    }

    private function resolve_notes_from_row( array $row ): string
    {
        $notes = isset( $row['notes'] ) ? (string) $row['notes'] : '';
        if ( '' !== trim( $notes ) ) {
            return $notes;
        }
        $customer_data = isset( $row['customer_data'] ) ? (string) $row['customer_data'] : '';
        if ( '' === trim( $customer_data ) ) {
            return '';
        }
        $decoded = json_decode( $customer_data, true );
        if ( ! is_array( $decoded ) ) {
            return '';
        }
        return isset( $decoded['notes'] ) ? (string) $decoded['notes'] : '';
    }

    /**
     * Retrieve all bookings for a provider, enriched with owner info.
     */
    public function getProviderBookings(int $provider_id): array
    {
        try {
            $rows = $this->db->get_provider_bookings($provider_id);

            $bookings = array_map(
                function (array $r) use ( $provider_id ): array {
                    $listing_id = (int) $r['listing_id'];
                    $service_id = isset( $r['service_id'] ) ? (int) $r['service_id'] : 0;
                    $availability_id = (int) $r['provider_id'];
                    $service_info = $this->get_service_snapshot( $service_id );
                    $service_label = $service_info['title'] ?? ( $r['service_label'] ?? '' );
                    $service_duration = $service_info['duration'] ?? 0;
                    if ( $service_duration < 1 ) {
                        $service_duration = $this->resolve_integration_service_duration( $listing_id, $service_label, $service_id );
                    }
                    if ( $service_duration < 1 ) {
                        $service_duration = $this->infer_duration_minutes(
                            $r['booking_date'] ?? null,
                            $r['booking_date_to'] ?? null,
                            $availability_id
                        );
                    }

                    $listing_location = $this->resolve_listing_location( $listing_id, (int) $r['id'] );
                    $listing_display = $this->get_listing_display_data( $listing_id, (int) $r['provider_id'] );

                    return [
                        'id'              => (int) $r['id'],
                        'listing_id'      => $listing_id,
                        'service_id'      => $service_id,
                        'order_id'        => isset( $r['order_id'] ) ? (int) $r['order_id'] : 0,
                        'provider_id'     => (int) $r['provider_id'],
                        'customer_id'    => (int) $r['customer_id'],
                        'customer_phone' => $r['customer_phone'],
                        'service_label'   => $service_label,
                        'service_price'   => $service_info['price'] ?? null,
                        'service_duration' => $service_duration ?: null,
                        'owner_name'      => $r['owner_name'] ?? '',
                        'owner_email'     => $r['owner_email'] ?? '',
                        'booking_date'    => $r['booking_date'],
                        'booking_date_to' => $r['booking_date_to'],
                        'created_at'      => $r['created_at'],
                        'price'           => isset( $r['price'] ) ? (float) $r['price'] : 0.0,
                        'price_formatted' => function_exists( 'bookaro_format_price' )
                            ? bookaro_format_price( $r['price'] ?? 0 )
                            : '',
                        'status'          => $r['status'],
                        'cancel_reason'   => $r['cancel_reason'] ?? '',
                        'notes'           => $this->resolve_notes_from_row( $r ),
                        'listing_title'   => (string) ( $listing_display['title'] ?? '' ),
                        'listing_url'     => (string) ( $listing_display['url'] ?? '' ),
                        'listing_image'   => (string) ( $listing_display['image'] ?? '' ),
                        'listing_location' => $listing_location,
                        'policy_snapshot' => $this->build_booking_policy_snapshot( $r, $provider_id ),
                    ];
                },
                $rows
            );
            $bookings = apply_filters( 'bookaro_provider_bookings', $bookings, $provider_id, $rows );
            return is_array( $bookings ) ? $bookings : [];
        } catch (\Throwable $e) {
            Logger::log('get_provider_bookings failed', [
                'exception' => $e->getMessage(),
                'code' => $e->getCode(),
                'trace' => $e->getTraceAsString(),
            ]);
            return [];
        }
    }

    /**
     * Retrieve all bookings for a customer, enriched with provider info.
     */
    public function getOwnerBookings(int $owner_id): array
    {
        try {
            $rows = $this->db->get_owner_bookings($owner_id);

            $bookings = array_map(
                function (array $r) use ( $owner_id ): array {
                    $listing_id = (int) $r['listing_id'];
                    $service_id = isset( $r['service_id'] ) ? (int) $r['service_id'] : 0;
                    $availability_id = (int) $r['provider_id'];
                    $service_info = $this->get_service_snapshot( $service_id );
                    $service_label = $service_info['title'] ?? ( $r['service_label'] ?? '' );
                    $service_duration = $service_info['duration'] ?? 0;
                    if ( $service_duration < 1 ) {
                        $service_duration = $this->resolve_integration_service_duration( $listing_id, $service_label, $service_id );
                    }
                    if ( $service_duration < 1 ) {
                        $service_duration = $this->infer_duration_minutes(
                            $r['booking_date'] ?? null,
                            $r['booking_date_to'] ?? null,
                            $availability_id
                        );
                    }

                    $listing_location = $this->resolve_listing_location( $listing_id, (int) $r['id'] );
                    $provider_id = (int) $r['provider_id'];
                    $provider_user = $provider_id > 0 ? get_user_by( 'ID', $provider_id ) : null;
                    $provider_name = sanitize_text_field( (string) ( $r['provider_name'] ?? '' ) );
                    if ( '' === $provider_name && $provider_user instanceof \WP_User ) {
                        $provider_name = sanitize_text_field( (string) $provider_user->display_name );
                    }
                    $provider_email = sanitize_email( (string) ( $r['provider_email'] ?? '' ) );
                    if ( '' === $provider_email && $provider_user instanceof \WP_User ) {
                        $provider_email = sanitize_email( (string) $provider_user->user_email );
                    }
                    $provider_phone = sanitize_text_field( (string) ( $r['provider_phone'] ?? '' ) );
                    if ( '' === $provider_phone && $provider_id > 0 ) {
                        $provider_phone = sanitize_text_field( (string) get_user_meta( $provider_id, 'billing_phone', true ) );
                    }
                    $listing_display = $this->get_listing_display_data( $listing_id, $provider_id );

                    return [
                        'id'              => (int) $r['id'],
                        'listing_id'      => $listing_id,
                        'service_id'      => $service_id,
                        'order_id'        => isset( $r['order_id'] ) ? (int) $r['order_id'] : 0,
                        'provider_id'     => (int) $r['provider_id'],
                        'service_label'   => $service_label,
                        'service_price'   => $service_info['price'] ?? null,
                        'service_duration' => $service_duration ?: null,
                        'booking_date'    => $r['booking_date'],
                        'booking_date_to' => $r['booking_date_to'],
                        'created_at'      => $r['created_at'],
                        'price'           => isset( $r['price'] ) ? (float) $r['price'] : 0.0,
                        'price_formatted' => function_exists( 'bookaro_format_price' )
                            ? bookaro_format_price( $r['price'] ?? 0 )
                            : '',
                        'status'          => $r['status'],
                        'cancel_reason'   => $r['cancel_reason'] ?? '',
                        'notes'           => $this->resolve_notes_from_row( $r ),
                        'provider_name'   => $provider_name,
                        'provider_email'  => $provider_email,
                        'provider_phone'  => $provider_phone,
                        'listing_title'   => (string) ( $listing_display['title'] ?? '' ),
                        'listing_url'     => (string) ( $listing_display['url'] ?? '' ),
                        'listing_image'   => (string) ( $listing_display['image'] ?? '' ),
                        'listing_location' => $listing_location,
                        'policy_snapshot' => $this->build_booking_policy_snapshot( $r, $owner_id ),
                    ];
                },
                $rows
            );
            $bookings = apply_filters( 'bookaro_owner_bookings', $bookings, $owner_id, $rows );
            return is_array( $bookings ) ? $bookings : [];
        } catch (\Throwable $e) {
            Logger::log('get_owner_bookings failed', [
                'exception' => $e->getMessage(),
                'code' => $e->getCode(),
                'trace' => $e->getTraceAsString(),
            ]);
            return [];
        }
    }

    /**
     * Cancel a booking and notify the other party.
     */
    public function cancelBooking(int $booking_id, int $user_id, string $reason = '', bool $is_admin_override = false)
    {
        try {
            $booking = $this->db->get_booking($booking_id);
            if (!$booking) {
                return new \WP_Error(
                    'bookaro_booking_missing',
                    __( 'Booking not found.', 'mybookaro' )
                );
            }
            // Bug 29: admins acting via the bulk-status path are neither the
            // booking's customer nor its provider, so without this override
            // the ownership check below 403'd every row of an admin bulk
            // cancel. BookingActionService::bulk_update_status reports the
            // failure per row and the UI just sees zero updates. The
            // override is opt-in: customer- and provider-initiated cancel
            // flows still hit the ownership check unchanged.
            if ( ! $is_admin_override
                && $booking->customer_id !== $user_id
                && $booking->provider_id !== $user_id
            ) {
                return new \WP_Error(
                    'bookaro_booking_forbidden',
                    __( 'You cannot cancel this booking.', 'mybookaro' )
                );
            }

            // Bug 28: refuse to flip a booking that has already reached a
            // terminal outcome. Without this guard a customer could call
            // /bookings/{id}/cancel against a completed / rejected / no_show
            // booking and overwrite the original outcome — also re-emitting
            // the cancellation email and clearing automation events. Reuse
            // the Bug 26 state machine so the rule stays in one place.
            $current_status = sanitize_key( (string) ( $booking->status ?? '' ) );
            if ( 'cancelled' === $current_status ) {
                // Already cancelled — no-op so retries don't fan out fresh
                // emails / hooks / lifecycle clears every time.
                return true;
            }
            $validation = $this->validateStatusChange( $booking_id, 'cancelled' );
            if ( $validation instanceof \WP_Error ) {
                return $validation;
            }

            $cancel_window_hours = absint( bookaro_get_option( 'booking_cancel_window_hours', 0 ) );
            $apply_to_providers = (bool) bookaro_get_option( 'booking_cancel_policy_apply_providers', 1 );
            $is_provider = (int) $booking->provider_id === (int) $user_id;
            // Admin overrides are explicit moderation actions, not customer
            // self-service — they're allowed to cancel inside the cancel
            // window the same way they're allowed to bypass the ownership
            // check. Keeping the window enforced for them would defeat the
            // whole point of giving admins a bulk-cancel button.
            if ( ! $is_admin_override && $cancel_window_hours > 0 && ( ! $is_provider || $apply_to_providers ) ) {
                $tz = wp_timezone();
                $start_dt = new \DateTime( $booking->booking_date, $tz );
                $cutoff = ( clone $start_dt )->modify( '-' . $cancel_window_hours . ' hours' );
                $now = new \DateTime( 'now', $tz );
                if ( $now > $cutoff ) {
                    return new \WP_Error(
                        'bookaro_cancel_window',
                        sprintf(
                            /* translators: %d: hours before booking start. */
                            __( 'Bookings can only be cancelled up to %d hours before start.', 'mybookaro' ),
                            $cancel_window_hours
                        )
                    );
                }
            }

            // Bug 35: cap before write (same rationale as addNote).
            $reason = $this->truncate_user_text( $reason );

            $this->db->update_booking($booking_id, [
                'status'        => 'cancelled',
                'cancel_reason' => $reason,
            ]);

            // Bug 30: the cancelled slot is now free again, but
            // get_available_slots' transient cache still has it pinned
            // as occupied for up to an hour. Customers hitting the
            // dashboard / booking modal would see the slot as unavailable
            // until the cache expires, and any concurrent reschedule
            // attempt would miss the newly-freed slot. Flush now.
            $this->db->flush_provider_slot_cache( (int) $booking->provider_id );

            // Clear lifecycle automations when the booking is cancelled.
            $this->clear_booking_automation_events( $booking_id, true, true );

            $actor_role = 'unknown';
            if ( $user_id === (int) $booking->provider_id ) {
                $actor_role = 'provider';
            } elseif ( $user_id === (int) $booking->customer_id ) {
                $actor_role = 'customer';
            }

            do_action( 'bookaro_booking_updated', [
                'booking_id'  => $booking_id,
                'provider_id' => $booking->provider_id,
                'customer_id' => $booking->customer_id,
                'listing_id'  => $booking->listing_id,
                'service_id'  => $booking->service_id,
                'actor_id'    => $user_id,
                'actor_role'  => $actor_role,
                'action'      => 'cancelled',
                'old_status'  => $booking->status,
                'new_status'  => 'cancelled',
                'date'        => $booking->booking_date,
                'date_to'     => $booking->booking_date_to,
            ] );

            $updated_booking = $this->db->get_booking( $booking_id );
            if ( $updated_booking ) {
                do_action( 'bookaro_booking_status_changed', $booking_id, 'cancelled', $updated_booking );
            }

            $action_feedback = $this->build_action_feedback_payload(
                'cancel',
                $booking,
                $updated_booking ?: $booking,
                $user_id,
                [ 'reason' => $reason ]
            );

            $owner    = get_user_by('ID', $booking->customer_id);
            $provider = get_user_by('ID', $booking->provider_id);
            if ($owner && $provider) {
                $recipient  = $user_id === $owner->ID ? $provider : $owner;
                $actor      = $user_id === $owner->ID ? $owner : $provider;
                $recipient_role = $recipient->ID === $provider->ID ? 'provider' : 'customer';
                ob_start();
                $this->emails->sendTemplatedEmail(
                    $recipient->user_email,
                    'cancelled',
                    [
                        'recipient'                    => UserHelper::preferred_name( $recipient ),
                        'recipient_role'               => $recipient_role,
                        'actor'                        => UserHelper::preferred_name( $actor ),
                        'owner'                        => UserHelper::preferred_name( $owner ),
                        'provider'                     => UserHelper::preferred_name( $provider ),
                        'service'                      => $booking->service_label,
                        'date'                         => $booking->booking_date,
                        'date_to'                      => $booking->booking_date_to,
                        'date_range'                   => BookingPolicyPresenter::format_date_range_label( $booking->booking_date, $booking->booking_date_to ),
                        'reason'                       => $reason,
                        'bookaro_action_summary'       => (string) ( $action_feedback['outcome']['summary'] ?? '' ),
                        'bookaro_action_details'       => is_array( $action_feedback['outcome']['details'] ?? null ) ? $action_feedback['outcome']['details'] : [],
                        'bookaro_action_payment_summary' => (string) ( $action_feedback['outcome']['payment_summary'] ?? '' ),
                    ],
                    $provider->ID,
                    [],
                    $booking_id
                );
                ob_end_clean();

                $replacements = [
                    'recipient'   => UserHelper::preferred_name( $recipient ),
                    'actor'       => UserHelper::preferred_name( $actor ),
                    'owner'       => UserHelper::preferred_name( $owner ),
                    'provider'    => UserHelper::preferred_name( $provider ),
                    'service'     => $booking->service_label,
                    'date'        => $booking->booking_date,
                    'date_to'     => $booking->booking_date_to,
                    'date_range'  => $this->format_date_range( $booking->booking_date, $booking->booking_date_to ),
                    'reason'      => $reason,
                    'payment_summary' => (string) ( $action_feedback['outcome']['payment_summary'] ?? '' ),
                ];
                $context = [
                    'actor_id' => $actor->ID,
                    'actor_role' => $actor_role,
                    'recipient_id' => $recipient->ID,
                    'recipient_role' => $recipient_role,
                    'provider_id' => $provider->ID,
                    'customer_id' => $owner->ID,
                    'listing_id' => (int) $booking->listing_id,
                    'service_id' => (int) $booking->service_id,
                    'reason' => $reason,
                    'payment_summary' => (string) ( $action_feedback['outcome']['payment_summary'] ?? '' ),
                    'booking' => $booking,
                    'replacements' => $replacements,
                    'source' => 'user',
                ];
                $this->dispatch_notification_event( 'cancelled', $booking_id, $context );
            }

            do_action( 'bookaro_booking_cancelled', [
                'booking_id'   => $booking_id,
                'provider_id'  => $booking->provider_id,
                'customer_id'  => $booking->customer_id,
                'listing_id'   => $booking->listing_id,
                'service_id'   => $booking->service_id,
                'actor_id'     => $user_id,
                'actor_role'   => $actor_role,
                'reason'       => $reason,
                'source'       => 'user',
                'old_status'   => $booking->status,
                'new_status'   => 'cancelled',
            ] );

            return $action_feedback;
        } catch (\Throwable $e) {
            Logger::log('cancel_booking failed', [
                'exception' => $e->getMessage(),
                'code'      => $e->getCode(),
                'trace'     => $e->getTraceAsString(),
            ]);
            return new \WP_Error(
                'bookaro_cancel_failed',
                __( 'Failed to cancel booking. Please try again.', 'mybookaro' )
            );
        }
    }

    /**
     * Reschedule an existing booking.
     *
     * @return bool|\WP_Error
     */
    public function rescheduleBooking(int $booking_id, int $user_id, array $data)
    {
        $lock_provider_id = 0;
        $lock_acquired = false;
        try {
            $booking = $this->db->get_booking( $booking_id );
            if ( ! $booking ) {
                return new \WP_Error( 'bookaro_booking_missing', __( 'Booking not found.', 'mybookaro' ) );
            }

            if ( $booking->customer_id !== $user_id && $booking->provider_id !== $user_id ) {
                return new \WP_Error( 'bookaro_booking_forbidden', __( 'You cannot reschedule this booking.', 'mybookaro' ) );
            }

            if ( in_array( $booking->status, [ 'cancelled', 'rejected', 'completed', 'no_show' ], true ) ) {
                return new \WP_Error(
                    'bookaro_booking_closed',
                    __( 'This booking cannot be rescheduled.', 'mybookaro' )
                );
            }

            $date_raw    = sanitize_text_field( $data['booking_date'] ?? '' );
            $date_to_raw = sanitize_text_field( $data['booking_date_to'] ?? '' );
            $time_raw    = sanitize_text_field( $data['time'] ?? '' );
            if ( $date_raw === '' ) {
                return new \WP_Error( 'bookaro_invalid_date', __( 'Please select a valid date.', 'mybookaro' ) );
            }

            $date_time_str = $time_raw ? sprintf( '%s %s', $date_raw, $time_raw ) : $date_raw;
            $dt = \DateTime::createFromFormat( 'Y-m-d H:i:s', $date_time_str )
                ?: \DateTime::createFromFormat( 'Y-m-d H:i', $date_time_str )
                ?: \DateTime::createFromFormat( 'Y-m-d', $date_time_str );
            if ( ! $dt ) {
                return new \WP_Error( 'bookaro_invalid_date', __( 'Please select a valid date.', 'mybookaro' ) );
            }

            // Reject reschedules to a past datetime. Same shape as the guard
            // in createBooking() (Bug 21) — the booking modal greys out
            // historical days, but a crafted reschedule payload could move
            // an existing booking backwards. The
            // `bookaro_allow_past_date_booking` filter lets server-side
            // flows opt out (admin back-dating a walk-in).
            $now_ts = (int) current_time( 'timestamp', true );
            $booking_ts = $dt->getTimestamp();
            if ( $booking_ts < $now_ts && ! apply_filters( 'bookaro_allow_past_date_booking', false, $dt, $data ) ) {
                return new \WP_Error(
                    'bookaro_past_date',
                    __( 'You cannot reschedule a booking to a date in the past. Please choose a future date.', 'mybookaro' )
                );
            }

            $provider_id    = (int) $booking->provider_id;
            $listing_id     = (int) $booking->listing_id;
            $service_id     = (int) ( $booking->service_id ?? 0 );
            $service_label  = (string) ( $booking->service_label ?? '' );
            $availability_id = $provider_id;
            $is_day_interval = $this->db->is_day_interval( $availability_id );

            if ( $is_day_interval ) {
                $dt->setTime( 0, 0, 0 );
            }
            $booking_date = $dt->format( 'Y-m-d H:i:s' );

            $service_duration = $this->resolve_booking_duration_minutes(
                $provider_id,
                $listing_id,
                $service_label,
                $service_id,
                $booking->booking_date,
                $booking->booking_date_to
            );

            $booking_date_to = null;
            if ( $is_day_interval ) {
                if ( $date_to_raw ) {
                    $dt2 = \DateTime::createFromFormat( 'Y-m-d', $date_to_raw );
                    if ( ! $dt2 ) {
                        return new \WP_Error( 'bookaro_invalid_date', __( 'Please select a valid end date.', 'mybookaro' ) );
                    }
                    $dt2->setTime( 0, 0, 0 );
                    if ( $dt2 < $dt ) {
                        return new \WP_Error( 'bookaro_invalid_range', __( 'End date must be after the start date.', 'mybookaro' ) );
                    }
                    // Bug 60: same cap as createBooking — reject ranges
                    // beyond the configured max (default 365 days) so a
                    // reschedule can't be abused to lock multi-year spans.
                    $range_days = (int) $dt->diff( $dt2 )->format( '%a' );
                    $max_range_days = (int) apply_filters( 'bookaro_max_booking_range_days', 365 );
                    if ( $max_range_days > 0 && $range_days > $max_range_days ) {
                        return new \WP_Error(
                            'bookaro_range_too_large',
                            sprintf(
                                /* translators: %d: maximum booking span in days. */
                                __( 'Booking range cannot exceed %d days.', 'mybookaro' ),
                                $max_range_days
                            )
                        );
                    }
                    $booking_date_to = $dt2->format( 'Y-m-d H:i:s' );
                }

                $duration_days = $service_duration > 0 ? (int) ceil( $service_duration / 1440 ) : 0;
                if ( $duration_days > 0 ) {
                    $min_end = clone $dt;
                    $min_end->modify( '+' . ( $duration_days - 1 ) . ' day' );
                    if ( empty( $booking_date_to ) || new \DateTime( $booking_date_to ) < $min_end ) {
                        $booking_date_to = $min_end->format( 'Y-m-d H:i:s' );
                    }
                } elseif ( empty( $booking_date_to ) && ! empty( $booking->booking_date_to ) ) {
                    $tz = wp_timezone();
                    $old_start = new \DateTime( $booking->booking_date, $tz );
                    $old_end   = new \DateTime( $booking->booking_date_to, $tz );
                    $days = (int) $old_start->diff( $old_end )->days + 1;
                    $end = clone $dt;
                    if ( $days > 1 ) {
                        $end->modify( '+' . ( $days - 1 ) . ' day' );
                    }
                    $booking_date_to = $end->format( 'Y-m-d H:i:s' );
                }
            } elseif ( $service_duration > 0 ) {
                $end_dt = clone $dt;
                $end_dt->modify( '+' . $service_duration . ' minutes' );
                $booking_date_to = $end_dt->format( 'Y-m-d H:i:s' );
            }

            if (
                $booking_date === (string) $booking->booking_date
                && (string) $booking_date_to === (string) ( $booking->booking_date_to ?? '' )
            ) {
                return new \WP_Error( 'bookaro_no_change', __( 'Please select a new time to reschedule.', 'mybookaro' ) );
            }

            $reschedule_window_hours = absint( bookaro_get_option( 'booking_reschedule_window_hours', 0 ) );
            if ( $reschedule_window_hours > 0 ) {
                $tz = wp_timezone();
                $start_dt = new \DateTime( $booking->booking_date, $tz );
                $cutoff = ( clone $start_dt )->modify( '-' . $reschedule_window_hours . ' hours' );
                $now = new \DateTime( 'now', $tz );
                if ( $now > $cutoff ) {
                    return new \WP_Error(
                        'bookaro_reschedule_window',
                        sprintf(
                            /* translators: %d: hours before booking start. */
                            __( 'Bookings can only be rescheduled up to %d hours before start.', 'mybookaro' ),
                            $reschedule_window_hours
                        )
                    );
                }
            }

            $tz = wp_timezone();
            $booking_start = new \DateTime( $booking_date, $tz );
            $booking_end = $booking_date_to ? new \DateTime( $booking_date_to, $tz ) : clone $booking_start;

            $min_notice_hours = absint( bookaro_get_option( 'booking_min_notice_hours', 0 ) );
            if ( $min_notice_hours > 0 ) {
                $min_start = ( new \DateTime( 'now', $tz ) )->modify( '+' . $min_notice_hours . ' hours' );
                if ( $booking_start < $min_start ) {
                    return new \WP_Error(
                        'bookaro_min_notice',
                        sprintf(
                            /* translators: %d: minimum notice hours before booking. */
                            __( 'Please book at least %d hours in advance.', 'mybookaro' ),
                            $min_notice_hours
                        )
                    );
                }
            }

            $max_window_days = absint( bookaro_get_option( 'booking_max_window_days', 0 ) );
            if ( $max_window_days > 0 ) {
                $max_end = ( new \DateTime( 'now', $tz ) )
                    ->modify( '+' . $max_window_days . ' days' )
                    ->setTime( 23, 59, 59 );
                if ( $booking_start > $max_end || $booking_end > $max_end ) {
                    return new \WP_Error(
                        'bookaro_max_window',
                        sprintf(
                            /* translators: %d: maximum booking window in days. */
                            __( 'Bookings can be made up to %d days in advance.', 'mybookaro' ),
                            $max_window_days
                        )
                    );
                }
            }

            $availability_duration = $service_duration;
            if ( $is_day_interval ) {
                $days = (int) $booking_start->diff( $booking_end )->days + 1;
                  $availability_duration = $days * 1440;
              }
              $availability_end = $booking_date_to ? $booking_end->format( 'Y-m-d' ) : $booking_start->format( 'Y-m-d' );

              $lock_provider_id = $availability_id;
              $lock = $this->acquire_booking_lock( $availability_id );
              if ( is_wp_error( $lock ) ) {
                  return $lock;
              }
              $lock_acquired = true;

              $available_slots = $this->db->get_available_slots(
                  $provider_id,
                  $booking_start->format( 'Y-m-d' ),
                  $availability_end,
                (int) $availability_duration,
                $booking_id
            );
            $slot_start = $booking_start->format( 'Y-m-d H:i:s' );
            $slot_ok = false;
            foreach ( $available_slots as $slot ) {
                if ( isset( $slot['start'] ) && $slot['start'] === $slot_start ) {
                    $slot_ok = true;
                    break;
                }
            }
            if ( ! $slot_ok ) {
                return new \WP_Error(
                    'bookaro_unavailable',
                    __( 'Selected time is no longer available. Please choose another slot.', 'mybookaro' )
                );
            }

            $updated = $this->db->update_booking( $booking_id, [
                'booking_date'    => $booking_date,
                'booking_date_to' => $booking_date_to,
            ] );

            if ( ! $updated ) {
                return new \WP_Error(
                    'bookaro_reschedule_failed',
                    __( 'Failed to reschedule booking. Please try again.', 'mybookaro' )
                );
            }

            $this->db->flush_provider_slot_cache( $provider_id );

            if ( in_array( (string) $booking->status, [ 'approved' ], true ) ) {
                $this->schedule_booking_reminders( $booking_id, $booking_date );
            } else {
                $this->clear_booking_automation_events( $booking_id, true, false );
            }

            $owner    = get_user_by( 'ID', $booking->customer_id );
            $provider = get_user_by( 'ID', $booking->provider_id );
            $current_booking_data = [];
            if ( is_object( $booking ) && method_exists( $booking, 'toArray' ) ) {
                $current_booking_data = (array) $booking->toArray();
            } elseif ( is_object( $booking ) ) {
                $current_booking_data = get_object_vars( $booking );
            } elseif ( is_array( $booking ) ) {
                $current_booking_data = $booking;
            }
            $action_feedback = $this->build_action_feedback_payload(
                'reschedule',
                $booking,
                (object) array_merge(
                    $current_booking_data,
                    [
                        'booking_date'    => $booking_date,
                        'booking_date_to' => $booking_date_to,
                    ]
                ),
                $user_id
            );
            if ( $owner && $provider ) {
                $recipient = $user_id === $owner->ID ? $provider : $owner;
                $actor     = $user_id === $owner->ID ? $owner : $provider;
                $recipient_role = $recipient->ID === $provider->ID ? 'provider' : 'customer';
                $dashboard_url = function_exists( 'bookaro_get_dashboard_url' )
                    ? bookaro_get_dashboard_url( [ 'tab' => 'bookings', 'listing_id' => (int) $booking->listing_id ] )
                    : site_url( '/my-account/bookings-and-management/?tab=bookings' );
                ob_start();
                $this->emails->sendTemplatedEmail(
                    $recipient->user_email,
                    'rescheduled',
                    [
                        'recipient'                    => UserHelper::preferred_name( $recipient ),
                        'recipient_role'               => $recipient_role,
                        'actor'                        => UserHelper::preferred_name( $actor ),
                        'owner'                        => UserHelper::preferred_name( $owner ),
                        'provider'                     => UserHelper::preferred_name( $provider ),
                        'service'                      => $service_label,
                        'date'                         => $booking_date,
                        'date_to'                      => $booking_date_to,
                        'date_range'                   => BookingPolicyPresenter::format_date_range_label( $booking_date, $booking_date_to ),
                        'old_date'                     => $booking->booking_date,
                        'old_date_to'                  => $booking->booking_date_to,
                        'old_date_range'               => BookingPolicyPresenter::format_date_range_label( $booking->booking_date, $booking->booking_date_to ),
                        'manage_booking_url'           => $dashboard_url,
                        'bookaro_action_summary'       => (string) ( $action_feedback['outcome']['summary'] ?? '' ),
                        'bookaro_action_details'       => is_array( $action_feedback['outcome']['details'] ?? null ) ? $action_feedback['outcome']['details'] : [],
                        'bookaro_action_payment_summary' => (string) ( $action_feedback['outcome']['payment_summary'] ?? '' ),
                    ],
                    $provider->ID,
                    [],
                    $booking_id
                );
                ob_end_clean();
            }

            if ( $owner && $provider ) {
                $recipient = $user_id === $owner->ID ? $provider : $owner;
                $actor     = $user_id === $owner->ID ? $owner : $provider;
                $recipient_role = $recipient->ID === $provider->ID ? 'provider' : 'customer';

                $replacements = [
                    'recipient'      => UserHelper::preferred_name( $recipient ),
                    'actor'          => UserHelper::preferred_name( $actor ),
                    'owner'          => UserHelper::preferred_name( $owner ),
                    'provider'       => UserHelper::preferred_name( $provider ),
                    'service'        => $service_label,
                    'date'           => $booking_date,
                    'date_to'        => $booking_date_to,
                    'date_range'     => $this->format_date_range( $booking_date, $booking_date_to ),
                    'old_date'       => $booking->booking_date,
                    'old_date_to'    => $booking->booking_date_to,
                    'old_date_range' => $this->format_date_range( $booking->booking_date, $booking->booking_date_to ),
                    'payment_summary' => (string) ( $action_feedback['outcome']['payment_summary'] ?? '' ),
                ];
                $context = [
                    'actor_id' => $actor->ID,
                    'recipient_id' => $recipient->ID,
                    'recipient_role' => $recipient_role,
                    'provider_id' => $provider->ID,
                    'customer_id' => $owner->ID,
                    'listing_id' => (int) $booking->listing_id,
                    'service_id' => (int) $booking->service_id,
                    'old_date' => $booking->booking_date,
                    'old_date_to' => $booking->booking_date_to,
                    'new_date' => $booking_date,
                    'new_date_to' => $booking_date_to,
                    'payment_summary' => (string) ( $action_feedback['outcome']['payment_summary'] ?? '' ),
                    'booking' => $booking,
                    'replacements' => $replacements,
                ];
                $this->dispatch_notification_event( 'rescheduled', $booking_id, $context );
            }

            do_action( 'bookaro_booking_updated', [
                'booking_id'  => $booking_id,
                'provider_id' => $booking->provider_id,
                'customer_id' => $booking->customer_id,
                'listing_id'  => $booking->listing_id,
                'service_id'  => $booking->service_id,
                'action'      => 'rescheduled',
                'date'        => $booking_date,
                'date_to'     => $booking_date_to,
                'old_date'    => $booking->booking_date,
                'old_date_to' => $booking->booking_date_to,
                'new_date'    => $booking_date,
                'new_date_to' => $booking_date_to,
            ] );

            return $action_feedback;
        } catch (\Throwable $e) {
            Logger::log('reschedule_booking failed', [
                'exception' => $e->getMessage(),
                'code'      => $e->getCode(),
                'trace'     => $e->getTraceAsString(),
            ]);
            return new \WP_Error(
                'bookaro_reschedule_failed',
                __( 'Failed to reschedule booking. Please try again.', 'mybookaro' )
            );
        } finally {
            if ( $lock_acquired ) {
                $this->release_booking_lock( $lock_provider_id );
            }
        }
    }

    /**
     * Cancel a booking from system context (e.g. WooCommerce order update).
     *
     * @return bool|\WP_Error
     */
    public function cancelBookingBySystem(int $booking_id, string $reason = '')
    {
        try {
            $booking = $this->db->get_booking($booking_id);
            if (!$booking) {
                return false;
            }
            if ( 'cancelled' === $booking->status ) {
                return true;
            }

            // Bug 35: cap before write (same rationale as cancelBooking).
            $reason = $this->truncate_user_text( $reason );

            $this->db->update_booking($booking_id, [
                'status'        => 'cancelled',
                'cancel_reason' => $reason,
            ]);

            $this->clear_booking_automation_events( $booking_id, true, true );

            do_action( 'bookaro_booking_updated', [
                'booking_id'  => $booking_id,
                'provider_id' => $booking->provider_id,
                'customer_id' => $booking->customer_id,
                'listing_id'  => $booking->listing_id,
                'service_id'  => $booking->service_id,
                'actor_id'    => 0,
                'actor_role'  => 'system',
                'action'      => 'cancelled',
                'old_status'  => $booking->status,
                'new_status'  => 'cancelled',
                'date'        => $booking->booking_date,
                'date_to'     => $booking->booking_date_to,
            ] );

            $updated_booking = $this->db->get_booking( $booking_id );
            if ( $updated_booking ) {
                do_action( 'bookaro_booking_status_changed', $booking_id, 'cancelled', $updated_booking );
            }

            $action_feedback = $this->build_action_feedback_payload(
                'cancel',
                $booking,
                $updated_booking ?: $booking,
                0,
                [ 'reason' => $reason ]
            );

            $owner    = get_user_by('ID', $booking->customer_id);
            $provider = get_user_by('ID', $booking->provider_id);
            if ($owner && $provider) {
                $recipients = [
                    [
                        'user' => $owner,
                        'role' => 'customer',
                    ],
                    [
                        'user' => $provider,
                        'role' => 'provider',
                    ],
                ];
                foreach ( $recipients as $recipient_item ) {
                    $recipient = $recipient_item['user'];
                    ob_start();
                    $this->emails->sendTemplatedEmail(
                        $recipient->user_email,
                        'cancelled',
                        [
                            'recipient'                    => UserHelper::preferred_name( $recipient ),
                            'recipient_role'               => (string) $recipient_item['role'],
                            'actor'                        => get_bloginfo( 'name' ),
                            'owner'                        => UserHelper::preferred_name( $owner ),
                            'provider'                     => UserHelper::preferred_name( $provider ),
                            'service'                      => $booking->service_label,
                            'date'                         => $booking->booking_date,
                            'date_to'                      => $booking->booking_date_to,
                            'date_range'                   => BookingPolicyPresenter::format_date_range_label( $booking->booking_date, $booking->booking_date_to ),
                            'reason'                       => $reason,
                            'bookaro_action_summary'       => (string) ( $action_feedback['outcome']['summary'] ?? '' ),
                            'bookaro_action_details'       => is_array( $action_feedback['outcome']['details'] ?? null ) ? $action_feedback['outcome']['details'] : [],
                            'bookaro_action_payment_summary' => (string) ( $action_feedback['outcome']['payment_summary'] ?? '' ),
                        ],
                        $provider->ID,
                        [],
                        $booking_id
                    );
                    ob_end_clean();
                }
            }

            if ( $owner && $provider ) {
                $recipient_map = [
                    [
                        'user'  => $owner,
                        'role'  => 'customer',
                    ],
                    [
                        'user'  => $provider,
                        'role'  => 'provider',
                    ],
                ];

                foreach ( $recipient_map as $recipient ) {
                    if ( empty( $recipient['user'] ) ) {
                        continue;
                    }
                    $replacements = [
                        'actor'      => get_bloginfo( 'name' ),
                        'owner'      => UserHelper::preferred_name( $owner ),
                        'provider'   => UserHelper::preferred_name( $provider ),
                        'service'    => $booking->service_label,
                        'date'       => $booking->booking_date,
                        'date_to'    => $booking->booking_date_to,
                        'date_range' => $this->format_date_range( $booking->booking_date, $booking->booking_date_to ),
                        'reason'     => $reason,
                        'recipient'  => UserHelper::preferred_name( $recipient['user'] ),
                        'payment_summary' => (string) ( $action_feedback['outcome']['payment_summary'] ?? '' ),
                    ];
                    $context = [
                        'actor' => get_bloginfo( 'name' ),
                        'recipient_id' => $recipient['user']->ID,
                        'recipient_role' => $recipient['role'],
                        'provider_id' => $provider->ID,
                        'customer_id' => $owner->ID,
                        'listing_id' => (int) $booking->listing_id,
                        'service_id' => (int) $booking->service_id,
                        'reason' => $reason,
                        'payment_summary' => (string) ( $action_feedback['outcome']['payment_summary'] ?? '' ),
                        'booking' => $booking,
                        'replacements' => $replacements,
                        'source' => 'system',
                    ];
                    $this->dispatch_notification_event( 'cancelled', $booking_id, $context );
                }
            }

            do_action( 'bookaro_booking_cancelled', [
                'booking_id'   => $booking_id,
                'provider_id'  => $booking->provider_id,
                'customer_id'  => $booking->customer_id,
                'listing_id'   => $booking->listing_id,
                'service_id'   => $booking->service_id,
                'actor_id'     => 0,
                'actor_role'   => 'system',
                'reason'       => $reason,
                'source'       => 'system',
                'old_status'   => $booking->status,
                'new_status'   => 'cancelled',
            ] );

            return $action_feedback;
        } catch (\Throwable $e) {
            Logger::log('cancel_booking_system failed', [
                'exception' => $e->getMessage(),
                'code'      => $e->getCode(),
                'trace'     => $e->getTraceAsString(),
            ]);
            return false;
        }
    }

    /**
     * Validate whether a booking status change is allowed.
     *
     * Bug 26: previously this returned null whenever nothing hooked the
     * filter, which let callers move a booking through any sequence of
     * statuses — including out of terminal outcomes. That meant an admin
     * could re-approve a `cancelled` booking (re-occupying its slot
     * without a fresh conflict check, and emailing a "confirmed" notice
     * to a customer who chose to cancel), reject a `completed` booking
     * (rewriting history), flip a `no_show` back to `completed`, etc.
     *
     * Enforce a baseline state machine here: bookings in a terminal
     * state cannot transition out. Pending and approved bookings remain
     * freely movable along the documented happy paths
     * (BookingActionService::update_status already restricts the target
     * enum to approved / rejected / completed / no_show).
     *
     * The filter still runs LAST, so Pro / themes / site code can:
     *   - tighten further (e.g. block approved->no_show until the slot
     *     time has passed)
     *   - or relax (return null after a permission re-check) when a
     *     legitimate admin-override flow needs to recover a mistakenly
     *     completed booking.
     */
    public function validateStatusChange(int $booking_id, string $new_status): ?\WP_Error
    {
        $new_status = sanitize_key( $new_status );
        $current = null;
        if ( $booking_id > 0 ) {
            $row = $this->db->get_booking( $booking_id );
            if ( $row ) {
                $current = sanitize_key( (string) ( $row->status ?? '' ) );
            }
        }

        $terminal_states = [ 'completed', 'rejected', 'cancelled', 'no_show' ];
        if ( $current !== null && $current !== '' && in_array( $current, $terminal_states, true ) ) {
            if ( $current !== $new_status ) {
                $default = new \WP_Error(
                    'bookaro_status_locked',
                    sprintf(
                        /* translators: 1: current terminal status, 2: requested new status */
                        __( 'This booking is %1$s and cannot be changed to %2$s.', 'mybookaro' ),
                        $current,
                        $new_status
                    ),
                    [ 'status' => 409 ]
                );
                $result = apply_filters( 'bookaro_validate_booking_status_change', $default, $booking_id, $new_status );
                return $result instanceof \WP_Error ? $result : null;
            }
        }

        $result = apply_filters( 'bookaro_validate_booking_status_change', null, $booking_id, $new_status );
        return $result instanceof \WP_Error ? $result : null;
    }

    /**
     * Check whether a user can manage a booking (provider ownership).
     */
    public function canManageBooking(int $booking_id, int $user_id): bool
    {
        if ( $booking_id < 1 || $user_id < 1 ) {
            return false;
        }
        $booking = $this->db->get_booking( $booking_id );
        if ( ! $booking ) {
            return false;
        }
        return (int) $booking->provider_id === (int) $user_id;
    }

    /**
     * Add or update a note on a booking.
     */
    public function addNote(int $booking_id, int $user_id, string $note): bool
    {
        try {
            $booking = $this->db->get_booking($booking_id);
            if (!$booking) {
                return false;
            }
            if ($booking->customer_id !== $user_id && $booking->provider_id !== $user_id) {
                return false;
            }

            // Bug 35: cap before write so the API contract matches what
            // ends up in the DB. Without this, MySQL silently truncates
            // at the TEXT column boundary (~65KB) in non-strict mode and
            // errors-then-gets-swallowed in strict mode — either way the
            // caller sees "Note saved" while the tail is lost.
            $note = $this->truncate_user_text( $note );

            $updated = $this->db->update_booking($booking_id, [
                'notes'         => $note,
            ]);
            if ( ! $updated ) {
                // update_booking returned false → DB layer rejected the
                // write (e.g. MySQL strict mode complaining about an
                // oversize value, or the booking row disappeared between
                // get_booking and update). Surface that to the caller
                // instead of pretending the note was saved.
                return false;
            }
            do_action( 'bookaro_booking_note_updated', $booking_id, $note, $user_id );

            return true;
        } catch (\Throwable $e) {
            Logger::log('add_note failed', [
                'exception' => $e->getMessage(),
                'code'      => $e->getCode(),
                'trace'     => $e->getTraceAsString(),
            ]);
            return false;
        }
    }

    /**
     * Update a booking's status and handle associated side effects.
     */
    public function updateBookingStatus(int $booking_id, string $new_status)
    {
        try {
            $new_status = sanitize_key( $new_status );
            $before = $this->db->get_booking( $booking_id );
            $old_status = $before ? sanitize_key( (string) $before->status ) : '';
            $this->db->update_booking( $booking_id, [ 'status' => $new_status ] );

            $booking = $this->db->get_booking( $booking_id );
            if ( ! $booking ) {
                return;
            }

            // Bug 30: status transitions can change whether the booking
            // occupies its slot (get_booked_slots excludes
            // cancelled/rejected/no_show). Without invalidating the
            // transient cache here, pending->rejected and approved->no_show
            // leave the slot pinned as occupied for up to an hour, and
            // pending->approved confirms a slot that other readers might
            // still see as bookable in the cache.
            $this->db->flush_provider_slot_cache( (int) $booking->provider_id );

            do_action( 'bookaro_booking_status_changed', $booking_id, $new_status, $booking );

            $owner = get_user_by( 'ID', $booking->customer_id );
            $provider = get_user_by( 'ID', $booking->provider_id );
            if ( ! $owner || ! $provider ) {
                return;
            }

            if ( $old_status === $new_status ) {
                return;
            }

            $actor_id = function_exists( 'get_current_user_id' ) ? (int) get_current_user_id() : 0;
            $actor_role = 'system';
            if ( $actor_id > 0 ) {
                if ( function_exists( 'current_user_can' ) && current_user_can( 'manage_options' ) ) {
                    $actor_role = 'admin';
                } elseif ( (int) $booking->provider_id === $actor_id ) {
                    $actor_role = 'provider';
                } elseif ( (int) $booking->customer_id === $actor_id ) {
                    $actor_role = 'customer';
                } else {
                    $actor_role = 'user';
                }
            }

            $new_status_label = BookingPolicyPresenter::get_status_label( $new_status );
            $old_status_label = BookingPolicyPresenter::get_status_label( $old_status );
            $status_change_summary = $new_status_label !== ''
                ? sprintf(
                    /* translators: %s: booking status label */
                    __( 'Booking status changed to %s.', 'mybookaro' ),
                    $new_status_label
                )
                : __( 'Booking status updated.', 'mybookaro' );
            $status_change_details = [];
            if ( $old_status_label !== '' ) {
                $status_change_details[] = sprintf(
                    /* translators: %s: previous booking status label */
                    __( 'Previous status: %s.', 'mybookaro' ),
                    $old_status_label
                );
            }

            $payment_summary = $this->build_status_change_payment_summary(
                $booking_id,
                $booking,
                $old_status,
                $new_status,
                $actor_id,
                $actor_role
            );

            if ( $new_status !== 'approved' ) {
                $this->clear_booking_automation_events( $booking_id, true, false );
            } else {
                $this->schedule_booking_reminders( $booking_id, (string) $booking->booking_date );
            }

            if ( $new_status !== 'completed' ) {
                $this->clear_booking_automation_events( $booking_id, false, true );
            } else {
                $this->schedule_booking_review_invite( $booking_id );
            }

            if ( $new_status === 'approved' ) {

                $dashboard_url = function_exists( 'bookaro_get_dashboard_url' )
                    ? bookaro_get_dashboard_url( [ 'tab' => 'bookings', 'listing_id' => (int) $booking->listing_id ] )
                    : site_url( '/my-account/bookings-and-management/?tab=bookings' );
                ob_start();
                $this->emails->sendTemplatedEmail(
                    $owner->user_email,
                    'approved',
                    [
                        'owner'    => UserHelper::preferred_name($owner),
                        'service'  => $booking->service_label,
                        'date'     => $booking->booking_date,
                        'date_to'  => $booking->booking_date_to,
                        'provider' => UserHelper::preferred_name($provider),
                        'manage_booking_url' => $dashboard_url,
                        'bookaro_action_summary' => $status_change_summary,
                        'bookaro_action_details' => $status_change_details,
                        'bookaro_action_payment_summary' => $payment_summary,
                        'payment_summary' => $payment_summary,
                    ],
                    $provider->ID,
                    [],
                    $booking_id
                );
                ob_end_clean();

                $replacements = [
                    'owner'      => UserHelper::preferred_name( $owner ),
                    'provider'   => UserHelper::preferred_name( $provider ),
                    'service'    => $booking->service_label,
                    'date'       => $booking->booking_date,
                    'date_to'    => $booking->booking_date_to,
                    'date_range' => $this->format_date_range( $booking->booking_date, $booking->booking_date_to ),
                    'bookaro_action_summary' => $status_change_summary,
                    'bookaro_action_details' => $status_change_details,
                    'bookaro_action_payment_summary' => $payment_summary,
                    'payment_summary' => $payment_summary,
                ];
                $context = [
                    'recipient_id' => $owner->ID,
                    'recipient_role' => 'customer',
                    'provider_id' => $provider->ID,
                    'customer_id' => $owner->ID,
                    'listing_id' => (int) $booking->listing_id,
                    'service_id' => (int) $booking->service_id,
                    'old_status' => $old_status,
                    'new_status' => $new_status,
                    'payment_summary' => $payment_summary,
                    'booking' => $booking,
                    'replacements' => $replacements,
                ];
                $this->dispatch_notification_event( 'approved', $booking_id, $context );
            }

            if ( 'rejected' === $new_status ) {
                $reason = isset( $booking->cancel_reason ) ? (string) $booking->cancel_reason : '';
                ob_start();
                $this->emails->sendTemplatedEmail(
                    $owner->user_email,
                    'rejected',
                    [
                        'owner'    => UserHelper::preferred_name($owner),
                        'service'  => $booking->service_label,
                        'date'     => $booking->booking_date,
                        'date_to'  => $booking->booking_date_to,
                        'provider' => UserHelper::preferred_name($provider),
                        'reason'   => $reason,
                        'bookaro_action_summary' => $status_change_summary,
                        'bookaro_action_details' => $status_change_details,
                        'bookaro_action_payment_summary' => $payment_summary,
                        'payment_summary' => $payment_summary,
                    ],
                    $provider->ID,
                    [],
                    $booking_id
                );
                ob_end_clean();

                $replacements = [
                    'owner'      => UserHelper::preferred_name( $owner ),
                    'provider'   => UserHelper::preferred_name( $provider ),
                    'service'    => $booking->service_label,
                    'date'       => $booking->booking_date,
                    'date_to'    => $booking->booking_date_to,
                    'date_range' => $this->format_date_range( $booking->booking_date, $booking->booking_date_to ),
                    'reason'     => $reason,
                    'bookaro_action_summary' => $status_change_summary,
                    'bookaro_action_details' => $status_change_details,
                    'bookaro_action_payment_summary' => $payment_summary,
                    'payment_summary' => $payment_summary,
                ];
                $context = [
                    'recipient_id' => $owner->ID,
                    'recipient_role' => 'customer',
                    'provider_id' => $provider->ID,
                    'customer_id' => $owner->ID,
                    'listing_id' => (int) $booking->listing_id,
                    'service_id' => (int) $booking->service_id,
                    'reason' => $reason,
                    'old_status' => $old_status,
                    'new_status' => $new_status,
                    'payment_summary' => $payment_summary,
                    'booking' => $booking,
                    'replacements' => $replacements,
                ];
                $this->dispatch_notification_event( 'rejected', $booking_id, $context );
            }

            do_action( 'bookaro_booking_updated', [
                'booking_id'  => $booking_id,
                'provider_id' => $booking->provider_id,
                'customer_id' => $booking->customer_id,
                'listing_id'  => $booking->listing_id,
                'service_id'  => $booking->service_id,
                'actor_id'    => $actor_id,
                'actor_role'  => $actor_role,
                'date'        => $booking->booking_date,
                'date_to'     => $booking->booking_date_to,
                'old_status'  => $old_status,
                'new_status'  => $new_status,
                'payment_summary' => $payment_summary,
            ] );
        } catch (\Throwable $e) {
            Logger::log('update_booking_status failed', [
                'exception' => $e->getMessage(),
                'code' => $e->getCode(),
                'trace' => $e->getTraceAsString(),
            ]);
        }
    }
}
