<?php
/**
 * Table Reservation helpers, availability, and notifications.
 *
 * @package Maca_Menu_List
 */

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

/**
 * Whether table reservation is enabled.
 *
 * @return bool
 */
function maca_menulist_table_booking_enabled() {
    if (!maca_menulist_pro_has_feature('table_bookings')) {
        return false;
    }

    return get_option('maca_menulist_table_booking', '0') === '1';
}

/**
 * Whether the current request is a block editor / REST block preview.
 *
 * @return bool
 */
function maca_menulist_is_rest_block_render() {
    return defined('REST_REQUEST') && REST_REQUEST;
}

/**
 * Register frontend assets for the table reservation form.
 *
 * @return void
 */
function maca_menulist_register_table_booking_assets() {
    if (!maca_menulist_pro_has_feature('table_bookings')) {
        return;
    }

    if (!wp_style_is('maca-menulist-style', 'registered')) {
        wp_register_style(
            'maca-menulist-style',
            MACA_MENULIST_PLUGIN_URL . 'assets/css/style.css',
            array(),
            MACA_MENULIST_VERSION
        );
    }

    if (!wp_script_is('maca-menulist-table-booking', 'registered')) {
        maca_menulist_register_table_booking_captcha_assets();

        $script_deps = array();
        if (maca_menulist_table_booking_captcha_enabled()) {
            $captcha_provider = maca_menulist_get_table_booking_captcha_provider();
            if ($captcha_provider === 'recaptcha_v2' || $captcha_provider === 'recaptcha_v3') {
                $script_deps[] = 'maca-menulist-recaptcha';
            } elseif ($captcha_provider === 'turnstile') {
                $script_deps[] = 'maca-menulist-turnstile';
            }
        }

        wp_register_script(
            'maca-menulist-table-booking',
            MACA_MENULIST_PLUGIN_URL . 'assets/js/table-booking.js',
            $script_deps,
            MACA_MENULIST_VERSION,
            true
        );
        wp_localize_script(
            'maca-menulist-table-booking',
            'macaMenulistTableBooking',
            maca_menulist_get_table_booking_script_config()
        );
    }
}

/**
 * Current date/time in the site timezone.
 *
 * @return DateTimeImmutable
 */
function maca_menulist_booking_now() {
    return new DateTimeImmutable('now', wp_timezone());
}

/**
 * Build a site-local datetime from date and time strings.
 *
 * @param string $date Date Y-m-d.
 * @param string $time Time HH:MM or HH:MM:SS.
 * @return DateTimeImmutable|null
 */
function maca_menulist_booking_make_datetime($date, $time) {
    $date = sanitize_text_field($date);
    if ($date === '') {
        return null;
    }

    $normalized = maca_menulist_normalize_time($time);
    if ($normalized === null) {
        return null;
    }

    $datetime = date_create_immutable($date . ' ' . $normalized, wp_timezone());

    return $datetime instanceof DateTimeImmutable ? $datetime : null;
}

/**
 * Whether any active booking tables exist.
 *
 * @return bool
 */
function maca_menulist_table_booking_has_active_tables() {
    maca_menulist_db_ensure_table_booking_tables();

    return !empty(maca_menulist_db_get_booking_tables(true));
}

/**
 * Count active tables that fit a party size.
 *
 * @param int $party_size Party size.
 * @return int
 */
function maca_menulist_table_booking_count_suitable_tables($party_size) {
    maca_menulist_db_ensure_table_booking_tables();

    $party_size = max(1, (int) $party_size);
    $count = 0;

    foreach (maca_menulist_db_get_booking_tables(true) as $table) {
        if ((int) $table->seats >= $party_size) {
            $count++;
        }
    }

    return $count;
}

/**
 * Explain why no booking times are available.
 *
 * @param string $date       Date Y-m-d.
 * @param int    $party_size Party size.
 * @return string
 */
function maca_menulist_table_booking_get_unavailable_message($date, $party_size = 2) {
    $party_size = max(1, (int) $party_size);

    if (!maca_menulist_table_booking_has_active_tables()) {
        return __('No tables are configured yet. Add tables under maca Restu → Table Reservations → Tables.', 'maca-restu-pro');
    }

    if (maca_menulist_table_booking_count_suitable_tables($party_size) === 0) {
        return sprintf(
            /* translators: %d: number of guests */
            __('No table has enough seats for %d guests. Try fewer guests or add a larger table in admin.', 'maca-restu-pro'),
            $party_size
        );
    }

    if (!maca_menulist_is_date_bookable($date)) {
        $exception = maca_menulist_get_booking_exception_for_date($date);

        if ($exception) {
            if (!empty($exception->note)) {
                return $exception->note;
            }

            if ($exception->exception_type === 'closed') {
                return _x('We are closed on this date.', 'booking schedule exception', 'maca-restu-pro');
            }

            if ($exception->exception_type === 'fully_booked') {
                return __('Fully booked on this date.', 'maca-restu-pro');
            }
        }

        $hours = maca_menulist_get_booking_hours_for_date($date);
        if (!empty($hours['note'])) {
            return $hours['note'];
        }

        if (!empty($hours['closed']) && empty($hours['bookable'])) {
            return _x('We are closed on this day of the week.', 'booking schedule exception', 'maca-restu-pro');
        }

        return __('Bookings are not available on this date.', 'maca-restu-pro');
    }

    $hours = maca_menulist_get_booking_hours_for_date($date);
    $windows = maca_menulist_get_hours_windows($hours);

    if (empty($windows)) {
        return __('Opening hours are not configured for this date.', 'maca-restu-pro');
    }

    $slot_duration = maca_menulist_table_booking_slot_duration();
    $has_valid_window = !empty(
        maca_menulist_generate_booking_time_slots_from_windows(
            $date,
            $windows,
            array('skip_past_today' => false)
        )
    );

    if (!$has_valid_window) {
        return sprintf(
            /* translators: 1: opening hours, 2: booking duration in minutes */
            __('Opening hours (%1$s) are shorter than the booking duration (%2$d minutes). Adjust hours or duration in settings.', 'maca-restu-pro'),
            maca_menulist_format_hours_windows_text($windows),
            $slot_duration
        );
    }

    if ($date === maca_menulist_booking_now()->format('Y-m-d')) {
        return __('No more booking times remain today. Try another date.', 'maca-restu-pro');
    }

    return __('No available times on this date.', 'maca-restu-pro');
}

/**
 * Slot duration in minutes.
 *
 * @return int
 */
function maca_menulist_table_booking_slot_duration() {
    $minutes = (int) get_option('maca_menulist_table_booking_slot_duration', 120);

    return max(30, min(480, $minutes));
}

/**
 * How many days ahead guests can book.
 *
 * @return int
 */
function maca_menulist_table_booking_advance_days() {
    $days = (int) get_option('maca_menulist_table_booking_advance_days', 60);

    return max(1, min(365, $days));
}

/**
 * Valid table assignment modes for guest bookings.
 *
 * @return array<int, string>
 */
function maca_menulist_table_booking_assignment_modes() {
    return array('standard', 'best_fit');
}

/**
 * How guests are assigned tables (standard = guest picks; best_fit = smallest table auto).
 *
 * @return string
 */
function maca_menulist_get_table_booking_assignment_mode() {
    $mode = get_option('maca_menulist_table_booking_assignment_mode', 'standard');

    if (!in_array($mode, maca_menulist_table_booking_assignment_modes(), true)) {
        return 'standard';
    }

    return $mode;
}

/**
 * Whether guest bookings use automatic best-fit table assignment.
 *
 * @return bool
 */
function maca_menulist_table_booking_uses_best_fit_assignment() {
    return maca_menulist_get_table_booking_assignment_mode() === 'best_fit';
}

/**
 * Valid booking statuses.
 *
 * @return array<int, string>
 */
function maca_menulist_get_booking_statuses() {
    return array('pending', 'confirmed', 'waitlist', 'cancelled', 'completed', 'no_show');
}

/**
 * Whether guests and staff may add waitlist entries.
 *
 * @return bool
 */
function maca_menulist_table_booking_waitlist_enabled() {
    if (!maca_menulist_table_booking_enabled()) {
        return false;
    }

    return get_option('maca_menulist_table_booking_waitlist', '1') === '1';
}

/**
 * Statuses that block table availability.
 *
 * @return array<int, string>
 */
function maca_menulist_get_blocking_booking_statuses() {
    return array('pending', 'confirmed');
}

/**
 * Whether a status key is valid.
 *
 * @param string $status Status key.
 * @return bool
 */
function maca_menulist_is_valid_booking_status($status) {
    return in_array(sanitize_key($status), maca_menulist_get_booking_statuses(), true);
}

/**
 * Valid exception types.
 *
 * @return array<int, string>
 */
function maca_menulist_get_booking_exception_types() {
    return array('closed', 'fully_booked', 'custom_hours');
}

/**
 * Whether an exception type is valid.
 *
 * @param string $type Exception type.
 * @return bool
 */
function maca_menulist_is_valid_booking_exception_type($type) {
    return in_array(sanitize_key($type), maca_menulist_get_booking_exception_types(), true);
}

/**
 * Human-readable booking status label.
 *
 * @param string $status Status key.
 * @return string
 */
function maca_menulist_get_booking_status_label($status) {
    $labels = array(
        'pending' => __('Väntar', 'maca-restu-pro'),
        'confirmed' => __('Bekräftad', 'maca-restu-pro'),
        'waitlist' => __('Väntlista', 'maca-restu-pro'),
        'cancelled' => __('Avbokad', 'maca-restu-pro'),
        'completed' => __('Avslutad', 'maca-restu-pro'),
        'no_show' => __('Utebliven', 'maca-restu-pro'),
    );

    $status = sanitize_key($status);

    return isset($labels[$status]) ? $labels[$status] : $status;
}

/**
 * Human-readable exception type label.
 *
 * @param string $type Exception type.
 * @return string
 */
function maca_menulist_get_booking_exception_type_label($type) {
    $labels = array(
        'closed' => __('Closed', 'maca-restu-pro'),
        'fully_booked' => __('Fully booked', 'maca-restu-pro'),
        'custom_hours' => __('Custom hours', 'maca-restu-pro'),
    );

    $type = sanitize_key($type);

    return isset($labels[$type]) ? $labels[$type] : $type;
}

/**
 * Admin description for each exception type.
 *
 * @param string $type Exception type.
 * @return string
 */
function maca_menulist_get_booking_exception_type_description($type) {
    $descriptions = array(
        'closed' => _x('Restaurant closed — no table reservations this day (e.g. holiday).', 'booking schedule exception', 'maca-restu-pro'),
        'fully_booked' => __('All tables reserved — no new bookings accepted, but the restaurant may still be open.', 'maca-restu-pro'),
        'custom_hours' => __('Different opening hours than the regular weekly schedule.', 'maca-restu-pro'),
    );

    $type = sanitize_key($type);

    return isset($descriptions[$type]) ? $descriptions[$type] : '';
}

/**
 * Effective schedule exception for a date (single-date rule wins over recurring weekday rule).
 *
 * @param string $date Date Y-m-d.
 * @return object|null
 */
function maca_menulist_get_booking_exception_for_date($date) {
    $date = sanitize_text_field($date);
    if ($date === '') {
        return null;
    }

    $single = maca_menulist_db_get_booking_exception_for_date($date);
    if ($single) {
        return $single;
    }

    return maca_menulist_db_get_recurring_booking_exception_for_date($date);
}

/**
 * Human-readable date range for a recurring exception.
 *
 * @param object $exception Recurring exception row.
 * @return string
 */
function maca_menulist_format_recurring_exception_date_range($exception) {
    if (!$exception) {
        return '';
    }

    $from = maca_menulist_format_wp_date((string) $exception->date_from);
    $to = maca_menulist_format_wp_date((string) $exception->date_to);

    if ($from === '' && $to === '') {
        return '';
    }

    if ($from === $to) {
        return $from;
    }

    return $from . '–' . $to;
}

/**
 * Weekday names for a recurring exception (admin lists).
 *
 * @param object $exception Recurring exception row.
 * @return string
 */
function maca_menulist_format_recurring_exception_weekdays($exception) {
    if (!$exception) {
        return '';
    }

    $days = maca_menulist_sort_weekdays_for_display(
        maca_menulist_parse_days_of_week($exception->days_of_week ?? '')
    );
    if (empty($days)) {
        return '';
    }

    $labels = maca_menulist_weekday_labels();
    $names = array();

    foreach ($days as $day) {
        if (isset($labels[ $day ])) {
            $names[] = $labels[ $day ];
        }
    }

    return implode(', ', $names);
}

/**
 * Recurring exceptions to show under a weekday in the opening hours block.
 *
 * @param int $day Weekday 0 (Sunday) – 6 (Saturday).
 * @return array<int, object>
 */
function maca_menulist_get_display_recurring_exceptions_for_weekday($day) {
    $day = (int) $day;
    $results = array();

    foreach (maca_menulist_db_get_booking_recurring_exceptions() as $exception) {
        $days = maca_menulist_parse_days_of_week($exception->days_of_week ?? '');
        if (!in_array($day, $days, true)) {
            continue;
        }

        if ($exception->exception_type === 'fully_booked') {
            continue;
        }

        $results[] = $exception;
    }

    return $results;
}

/**
 * Guest-facing line for one recurring rule under a weekday row.
 *
 * @param object $exception Recurring exception row.
 * @return string
 */
function maca_menulist_format_opening_hours_recurring_line($exception) {
    if (!$exception) {
        return '';
    }

    $range = maca_menulist_format_recurring_exception_date_range($exception);
    $type = sanitize_key((string) $exception->exception_type);

    if ($type === 'custom_hours' && !empty($exception->time_start) && !empty($exception->time_end)) {
        $text = substr((string) $exception->time_start, 0, 5) . '–' . substr((string) $exception->time_end, 0, 5);
        if ($range !== '') {
            $text .= ', ' . $range;
        }
    } elseif ($type === 'closed') {
        $text = __('Closed', 'maca-restu-pro');
        if ($range !== '') {
            $text .= ', ' . $range;
        }
    } else {
        return '';
    }

    $note = isset($exception->note) ? trim((string) $exception->note) : '';
    if ($note !== '') {
        $text .= ' (' . $note . ')';
    }

    return $text;
}

/**
 * Default weekly opening hours (0 = Sunday).
 *
 * @return array<int, array{closed: bool, open: string, close: string}>
 */
function maca_menulist_get_default_booking_hours() {
    $defaults = array();

    for ($day = 0; $day <= 6; $day++) {
        $defaults[ $day ] = maca_menulist_normalize_stored_day_hours(
            array(
                'closed'  => false,
                'windows' => array(
                    array(
                        'open'  => '11:00',
                        'close' => '22:00',
                    ),
                ),
            )
        );
    }

    return $defaults;
}

/**
 * Weekly opening hours from settings.
 *
 * @return array<int, array{closed: bool, open: string, close: string}>
 */
function maca_menulist_get_booking_hours() {
    $stored = get_option('maca_menulist_table_booking_hours', array());
    $defaults = maca_menulist_get_default_booking_hours();

    if (!is_array($stored)) {
        return $defaults;
    }

    for ($day = 0; $day <= 6; $day++) {
        $day_data = null;
        if (isset($stored[$day]) && is_array($stored[$day])) {
            $day_data = $stored[$day];
        } elseif (isset($stored[(string) $day]) && is_array($stored[(string) $day])) {
            $day_data = $stored[(string) $day];
        }

        if ($day_data === null) {
            continue;
        }

        $defaults[ $day ] = maca_menulist_normalize_stored_day_hours($day_data, $defaults[ $day ]['open'], $defaults[ $day ]['close']);
        $defaults[ $day ]['closed'] = !empty($day_data['closed']);
    }

    return $defaults;
}

/**
 * Save weekly opening hours.
 *
 * @param array<int, array<string, mixed>> $hours Hours per weekday.
 * @return void
 */
function maca_menulist_save_booking_hours($hours) {
    $clean = maca_menulist_get_default_booking_hours();

    if (!is_array($hours)) {
        update_option('maca_menulist_table_booking_hours', $clean);
        return;
    }

    for ($day = 0; $day <= 6; $day++) {
        if (!isset($hours[ $day ]) || !is_array($hours[ $day ])) {
            continue;
        }

        $parsed = maca_menulist_parse_day_hours_from_request($hours[ $day ]);
        if ($parsed === null) {
            continue;
        }

        $clean[ $day ] = $parsed;
    }

    update_option('maca_menulist_table_booking_hours', $clean);
}

/**
 * Resolve effective hours for a date (weekly + exception).
 *
 * @param string $date Date Y-m-d.
 * @return array{bookable: bool, closed: bool, open: string|null, close: string|null, note: string}
 */
function maca_menulist_get_booking_hours_for_date($date) {
    $date = sanitize_text_field($date);
    $result = array(
        'bookable' => false,
        'closed' => true,
        'open' => null,
        'close' => null,
        'note' => '',
    );

    if ($date === '') {
        return $result;
    }

    $exception = maca_menulist_get_booking_exception_for_date($date);

    if ($exception) {
        $result['note'] = isset($exception->note) ? (string) $exception->note : '';

        if ($exception->exception_type === 'closed' || $exception->exception_type === 'fully_booked') {
            return $result;
        }

        if ($exception->exception_type === 'custom_hours') {
            $open = !empty($exception->time_start) ? substr($exception->time_start, 0, 5) : null;
            $close = !empty($exception->time_end) ? substr($exception->time_end, 0, 5) : null;
            $window = ($open && $close) ? maca_menulist_sanitize_hours_window($open, $close) : null;

            if ($window !== null) {
                return maca_menulist_build_bookable_hours_result(array($window), $result['note']);
            }

            return $result;
        }
    }

    $day_dt = maca_menulist_booking_make_datetime($date, '12:00');
    $day = $day_dt ? (int) $day_dt->format('w') : (int) wp_date('w', (int) maca_menulist_wp_date_to_timestamp($date));
    $hours = maca_menulist_get_booking_hours();
    $day_hours = isset($hours[ $day ]) ? $hours[ $day ] : maca_menulist_normalize_stored_day_hours(array('closed' => true));

    if (!empty($day_hours['closed'])) {
        return $result;
    }

    return maca_menulist_build_bookable_hours_result($day_hours['windows']);
}

/**
 * Whether the restaurant is open for service at a date/time (weekly hours + exceptions; fully booked days still have hours).
 *
 * @param string      $date Date Y-m-d.
 * @param string|null $time Time HH:MM or HH:MM:SS; omit to only check that the date has service hours.
 * @return bool
 */
function maca_menulist_is_restaurant_open_at($date, $time = null) {
    $date = sanitize_text_field($date);
    if ($date === '') {
        return false;
    }

    $hours = maca_menulist_get_waitlist_hours_for_date($date);
    if (!maca_menulist_hours_are_bookable($hours)) {
        return false;
    }

    if ($time === null) {
        return true;
    }

    $time_norm = maca_menulist_normalize_time($time);
    if ($time_norm === null) {
        return false;
    }

    return maca_menulist_is_service_time_in_hours_windows($date, $time_norm, maca_menulist_get_hours_windows($hours));
}

/**
 * Whether the restaurant is open for service right now (site timezone).
 *
 * @return bool
 */
function maca_menulist_is_restaurant_open_now() {
    $now = maca_menulist_booking_now();

    return maca_menulist_is_restaurant_open_at($now->format('Y-m-d'), $now->format('H:i:s'));
}

/**
 * Whether a date can accept bookings.
 *
 * @param string $date Date Y-m-d.
 * @return bool
 */
function maca_menulist_is_date_bookable($date) {
    $date = sanitize_text_field($date);
    if ($date === '') {
        return false;
    }

    $today = maca_menulist_booking_now()->format('Y-m-d');
    $max_date = maca_menulist_booking_now()->modify('+' . maca_menulist_table_booking_advance_days() . ' days')->format('Y-m-d');

    if ($date < $today || $date > $max_date) {
        return false;
    }

    $hours = maca_menulist_get_booking_hours_for_date($date);

    return !empty($hours['bookable']);
}

/**
 * Opening hours for waitlist (includes fully booked days; excludes closed).
 *
 * @param string $date Date Y-m-d.
 * @return array{bookable: bool, closed: bool, open: string|null, close: string|null, note: string}
 */
function maca_menulist_get_waitlist_hours_for_date($date) {
    $date = sanitize_text_field($date);
    $result = array(
        'bookable' => false,
        'closed' => true,
        'open' => null,
        'close' => null,
        'note' => '',
    );

    if ($date === '') {
        return $result;
    }

    $exception = maca_menulist_get_booking_exception_for_date($date);

    if ($exception) {
        $result['note'] = isset($exception->note) ? (string) $exception->note : '';

        if ($exception->exception_type === 'closed') {
            return $result;
        }

        if ($exception->exception_type === 'custom_hours') {
            $open = !empty($exception->time_start) ? substr($exception->time_start, 0, 5) : null;
            $close = !empty($exception->time_end) ? substr($exception->time_end, 0, 5) : null;
            $window = ($open && $close) ? maca_menulist_sanitize_hours_window($open, $close) : null;

            if ($window !== null) {
                return maca_menulist_build_bookable_hours_result(array($window), $result['note']);
            }

            return $result;
        }
    }

    $day_dt = maca_menulist_booking_make_datetime($date, '12:00');
    $day = $day_dt ? (int) $day_dt->format('w') : (int) wp_date('w', (int) maca_menulist_wp_date_to_timestamp($date));
    $hours = maca_menulist_get_booking_hours();
    $day_hours = isset($hours[ $day ]) ? $hours[ $day ] : maca_menulist_normalize_stored_day_hours(array('closed' => true));

    if (!empty($day_hours['closed'])) {
        return $result;
    }

    return maca_menulist_build_bookable_hours_result($day_hours['windows']);
}

/**
 * Whether a date accepts waitlist sign-ups.
 *
 * @param string $date Date Y-m-d.
 * @return bool
 */
function maca_menulist_is_date_waitlist_eligible($date) {
    $date = sanitize_text_field($date);
    if ($date === '') {
        return false;
    }

    $today = maca_menulist_booking_now()->format('Y-m-d');
    $max_date = maca_menulist_booking_now()->modify('+' . maca_menulist_table_booking_advance_days() . ' days')->format('Y-m-d');

    if ($date < $today || $date > $max_date) {
        return false;
    }

    $hours = maca_menulist_get_waitlist_hours_for_date($date);

    return maca_menulist_hours_are_bookable($hours);
}

/**
 * Time slots for waitlist (all times in opening hours, no table filter).
 *
 * @param string $date Date Y-m-d.
 * @return array<int, string> HH:MM slots.
 */
function maca_menulist_get_waitlist_time_slots($date) {
    $date = sanitize_text_field($date);
    if ($date === '' || !maca_menulist_is_date_waitlist_eligible($date)) {
        return array();
    }

    if (!maca_menulist_table_booking_has_active_tables()) {
        return array();
    }

    $hours = maca_menulist_get_waitlist_hours_for_date($date);
    if (!maca_menulist_hours_are_bookable($hours)) {
        return array();
    }

    return maca_menulist_generate_booking_time_slots_from_windows(
        $date,
        maca_menulist_get_hours_windows($hours)
    );
}

/**
 * Guest-facing time slots (includes waitlist-only times when best-fit hides full slots).
 *
 * @param string $date       Date Y-m-d.
 * @param int    $party_size Party size.
 * @return array<int, string> HH:MM slots.
 */
function maca_menulist_get_guest_booking_time_slots($date, $party_size = 2) {
    $slots = maca_menulist_get_available_time_slots($date, $party_size);

    if (!maca_menulist_table_booking_waitlist_enabled() || !maca_menulist_table_booking_uses_best_fit_assignment()) {
        return $slots;
    }

    foreach (maca_menulist_get_waitlist_time_slots($date) as $slot) {
        if (!in_array($slot, $slots, true)) {
            $slots[] = $slot;
        }
    }

    sort($slots);

    return $slots;
}

/**
 * Generate time slots for a date.
 *
 * @param string $date       Date Y-m-d.
 * @param int    $party_size Party size.
 * @return array<int, string> HH:MM slots.
 */
function maca_menulist_get_available_time_slots($date, $party_size = 2) {
    $party_size = max(1, (int) $party_size);

    if (!maca_menulist_is_date_bookable($date)) {
        return array();
    }

    if (!maca_menulist_table_booking_has_active_tables()) {
        return array();
    }

    if (maca_menulist_table_booking_count_suitable_tables($party_size) === 0) {
        return array();
    }

    $hours = maca_menulist_get_booking_hours_for_date($date);
    if (!maca_menulist_hours_are_bookable($hours)) {
        return array();
    }

    return maca_menulist_generate_booking_time_slots_from_windows(
        $date,
        maca_menulist_get_hours_windows($hours),
        array(
            'filter' => maca_menulist_table_booking_uses_best_fit_assignment()
                ? static function ($slot) use ($date, $party_size) {
                    return !empty(maca_menulist_get_available_tables($date, $slot, $party_size));
                }
                : null,
        )
    );
}

/**
 * Time slots for admin floor overview (includes past slots today; no guest filters).
 *
 * @param string $date Date Y-m-d.
 * @return array<int, string> HH:MM slots.
 */
function maca_menulist_get_admin_booking_time_slots($date) {
    $date = sanitize_text_field($date);
    if ($date === '') {
        return array();
    }

    $hours = maca_menulist_get_booking_hours_for_date($date);
    if (!maca_menulist_hours_are_bookable($hours)) {
        return array();
    }

    return maca_menulist_generate_booking_time_slots_from_windows(
        $date,
        maca_menulist_get_hours_windows($hours),
        array(
            'skip_past_today' => false,
        )
    );
}

/**
 * Number of full booking-duration sittings in the day's opening hours.
 *
 * Example: 16:00–20:00 (240 min) with 120 min booking duration → 2 sittings.
 * This is opening window ÷ booking duration, not the count of 30-minute start times.
 *
 * @param string $date Date Y-m-d.
 * @return int
 */
function maca_menulist_get_date_booking_sitting_count($date) {
    $date = sanitize_text_field($date);
    if ($date === '') {
        return 0;
    }

    $hours = maca_menulist_get_booking_hours_for_date($date);
    if (!maca_menulist_hours_are_bookable($hours)) {
        return 0;
    }

    return maca_menulist_count_booking_sittings_from_windows(
        $date,
        maca_menulist_get_hours_windows($hours)
    );
}

/**
 * Max concurrent bookings per table in admin floor view (staff may share tables).
 *
 * @return int
 */
function maca_menulist_admin_floor_max_bookings_per_table() {
    return 4;
}

/**
 * Whether a physical table is marked splittable in admin (floor overview).
 *
 * @param object|null $table Table row.
 * @return bool
 */
function maca_menulist_table_is_splittable($table) {
    return is_object($table) && !empty($table->is_splittable);
}

/**
 * Max guests per share slot on a splittable table.
 *
 * @param object|null $table Table row.
 * @return int
 */
function maca_menulist_table_split_party_size($table) {
    if (!maca_menulist_table_is_splittable($table)) {
        return 0;
    }

    $seats = is_object($table) ? max(1, (int) $table->seats) : 2;
    $split = is_object($table) && isset($table->split_party_size) ? (int) $table->split_party_size : 2;

    return max(1, min($seats, $split));
}

/**
 * Seat dot groups for admin floor cards (wrapper; see split-layout).
 *
 * @param int                $total_seats Seats on the card (unused when capacities set).
 * @param int                $slot_size   Uniform slot size (0 = use capacities array via table).
 * @param array<int, object> $bookings    Bookings at this time.
 * @param array<int, int>    $capacities  Optional per-slot capacities.
 * @return array<int, array{capacity: int, occupied: int}>
 */
function maca_menulist_build_floor_seat_slots($total_seats, $slot_size, $bookings, $capacities = null) {
    if (is_array($capacities) && !empty($capacities)) {
        return maca_menulist_build_floor_seat_slots_from_capacities($capacities, $bookings);
    }

    $total_seats = max(0, (int) $total_seats);
    $slot_size = max(0, (int) $slot_size);

    if ($total_seats <= 0) {
        return array();
    }

    if ($slot_size <= 0) {
        $occupied = 0;

        foreach ($bookings as $booking) {
            $occupied += max(1, (int) $booking->party_size);
        }

        return array(
            array(
                'capacity' => $total_seats,
                'occupied' => min($total_seats, $occupied),
            ),
        );
    }

    $num_slots = (int) max(1, floor($total_seats / $slot_size));
    $uniform = array();

    for ($i = 0; $i < $num_slots; $i++) {
        $uniform[] = $slot_size;
    }

    return maca_menulist_build_floor_seat_slots_from_capacities($uniform, $bookings);
}

/**
 * All blocking bookings overlapping a date/time on one table.
 *
 * @param int    $table_id Table ID.
 * @param string $date     Date Y-m-d.
 * @param string $time     Time HH:MM.
 * @return array<int, object>
 */
function maca_menulist_get_table_bookings_at_time($table_id, $date, $time, $split_slot = null) {
    $table_id = absint($table_id);
    $time_norm = maca_menulist_normalize_time($time);
    $filter_slot = null;

    if ($split_slot !== null && function_exists('maca_menulist_normalize_split_slot')) {
        $filter_slot = maca_menulist_normalize_split_slot((string) $split_slot);
        if ($filter_slot === '') {
            $filter_slot = null;
        }
    }

    if ($table_id <= 0 || $time_norm === null) {
        return array();
    }

    $duration = maca_menulist_table_booking_slot_duration();
    $bookings = array();
    $split_for_date = function_exists('maca_menulist_is_table_split_for_date')
        && maca_menulist_is_table_split_for_date($table_id, $date);
    $table_row = $split_for_date ? maca_menulist_db_get_booking_table($table_id) : null;

    foreach (maca_menulist_db_get_table_bookings_for_date($date, $table_id) as $booking) {
        if (!in_array((string) $booking->status, maca_menulist_get_blocking_booking_statuses(), true)) {
            continue;
        }

        if ($split_for_date && $filter_slot !== null) {
            if (!$table_row) {
                continue;
            }

            if (!maca_menulist_booking_uses_day_split_slot($booking, $filter_slot, $table_row, $date)) {
                continue;
            }
        } elseif ($split_for_date && $filter_slot === null) {
            continue;
        } elseif (!$split_for_date && !empty($booking->split_slot)) {
            continue;
        }

        if (maca_menulist_booking_times_overlap($time_norm, $booking->booking_time, $duration)) {
            $bookings[] = $booking;
        }
    }

    usort(
        $bookings,
        static function ($a, $b) {
            $time_cmp = strcmp((string) $a->booking_time, (string) $b->booking_time);
            if ($time_cmp !== 0) {
                return $time_cmp;
            }

            return (int) $a->id <=> (int) $b->id;
        }
    );

    return $bookings;
}

/**
 * First blocking booking at a date/time (legacy helper).
 *
 * @param int    $table_id Table ID.
 * @param string $date     Date Y-m-d.
 * @param string $time     Time HH:MM.
 * @return object|null
 */
function maca_menulist_get_table_booking_at_time($table_id, $date, $time) {
    $bookings = maca_menulist_get_table_bookings_at_time($table_id, $date, $time);

    return $bookings[0] ?? null;
}

/**
 * Occupied seats on a table at a date/time (sum of party sizes).
 *
 * @param int    $table_id   Table ID.
 * @param string $date       Date Y-m-d.
 * @param string $time       Time HH:MM.
 * @param int    $exclude_id Booking ID to exclude from the sum.
 * @return int
 */
function maca_menulist_get_table_occupied_seats_at_time($table_id, $date, $time, $exclude_id = 0) {
    $exclude_id = absint($exclude_id);
    $occupied = 0;

    foreach (maca_menulist_get_table_bookings_at_time($table_id, $date, $time) as $booking) {
        if ($exclude_id > 0 && (int) $booking->id === $exclude_id) {
            continue;
        }

        $occupied += max(1, (int) $booking->party_size);
    }

    return $occupied;
}

/**
 * Whether staff may place another booking on a table at a date/time (floor overview).
 *
 * @param int    $table_id           Table ID.
 * @param string $date               Date Y-m-d.
 * @param string $time               Time HH:MM.
 * @param int    $party_size         Party size to place.
 * @param int    $exclude_booking_id Booking being moved (excluded from counts).
 * @return bool
 */
function maca_menulist_admin_table_can_accept_booking($table_id, $date, $time, $party_size, $exclude_booking_id = 0, $split_slot = '') {
    $table_id = absint($table_id);
    $exclude_booking_id = absint($exclude_booking_id);
    $party_size = max(1, (int) $party_size);
    $split_slot = function_exists('maca_menulist_normalize_split_slot')
        ? maca_menulist_normalize_split_slot((string) $split_slot)
        : '';

    $table = maca_menulist_db_get_booking_table($table_id);
    if (!$table || !(int) $table->is_active) {
        return false;
    }

    if (function_exists('maca_menulist_is_table_split_for_date') && maca_menulist_is_table_split_for_date($table_id, $date)) {
        if ($split_slot === '') {
            return false;
        }

        $split_size = maca_menulist_table_split_slot_capacity($table, $split_slot, $date);
        if ($split_size <= 0 || $party_size > $split_size) {
            return false;
        }

        foreach (maca_menulist_get_table_bookings_at_time($table_id, $date, $time, $split_slot) as $booking) {
            if ($exclude_booking_id > 0 && (int) $booking->id === $exclude_booking_id) {
                continue;
            }

            return false;
        }

        return true;
    }

    if ($party_size > (int) $table->seats) {
        return false;
    }

    $capacities = maca_menulist_get_table_split_capacities($table, $date);
    $max_bookings = maca_menulist_table_is_splittable($table)
        ? count($capacities)
        : maca_menulist_admin_floor_max_bookings_per_table();
    $bookings_at_time = array();

    foreach (maca_menulist_get_table_bookings_at_time($table_id, $date, $time) as $booking) {
        if ($exclude_booking_id > 0 && (int) $booking->id === $exclude_booking_id) {
            continue;
        }

        $bookings_at_time[] = $booking;
    }

    if (count($bookings_at_time) >= $max_bookings) {
        return false;
    }

    if (maca_menulist_table_is_splittable($table)) {
        return maca_menulist_splittable_bookings_fit_capacities(
            $capacities,
            maca_menulist_get_table_bookings_at_time($table_id, $date, $time),
            $party_size,
            $exclude_booking_id
        );
    }

    $occupied = maca_menulist_get_table_occupied_seats_at_time($table_id, $date, $time, $exclude_booking_id);

    return ($occupied + $party_size) <= (int) $table->seats;
}

/**
 * Floor overview payload for admin (tables + availability at one time).
 *
 * @param string $date Date Y-m-d.
 * @param string $time Time HH:MM.
 * @return array<string, mixed>
 */
function maca_menulist_get_admin_booking_floor_status($date, $time) {
    $date = sanitize_text_field($date);
    $time_norm = maca_menulist_normalize_time($time);
    $slot_duration = maca_menulist_table_booking_slot_duration();

    if ($date === '' || $time_norm === null) {
        return array(
            'closed' => true,
            'message' => __('Invalid date or time.', 'maca-restu-pro'),
            'tables' => array(),
            'summary' => array('total' => 0, 'available' => 0, 'occupied' => 0),
        );
    }

    if (!maca_menulist_table_booking_has_active_tables()) {
        return array(
            'closed' => true,
            'message' => __('No tables are configured yet.', 'maca-restu-pro'),
            'tables' => array(),
            'summary' => array('total' => 0, 'available' => 0, 'occupied' => 0),
        );
    }

    $hours = maca_menulist_get_booking_hours_for_date($date);
    if (!maca_menulist_hours_are_bookable($hours)) {
        return array(
            'closed' => true,
            'message' => maca_menulist_table_booking_get_unavailable_message($date, 1),
            'tables' => array(),
            'summary' => array('total' => 0, 'available' => 0, 'occupied' => 0),
        );
    }

    if (!maca_menulist_is_booking_start_in_hours_windows($date, $time_norm, maca_menulist_get_hours_windows($hours), $slot_duration)) {
        return array(
            'closed' => true,
            'message' => __('Selected time is outside opening hours.', 'maca-restu-pro'),
            'tables' => array(),
            'summary' => array('total' => 0, 'available' => 0, 'occupied' => 0),
        );
    }

    maca_menulist_db_ensure_table_booking_tables();
    $tables = maca_menulist_db_get_booking_tables(true);

    usort(
        $tables,
        static function ($a, $b) {
            $sort_cmp = (int) $a->sort_order <=> (int) $b->sort_order;
            if ($sort_cmp !== 0) {
                return $sort_cmp;
            }

            return strnatcasecmp((string) $a->table_number, (string) $b->table_number);
        }
    );

    $payload_tables = array();
    $available_count = 0;
    $partial_count = 0;
    $full_count = 0;
    foreach ($tables as $table) {
        $table_id = (int) $table->id;
        $is_splittable = maca_menulist_table_is_splittable($table);
        $split_for_date = $is_splittable && maca_menulist_is_table_split_for_date($table_id, $date);

        if ($split_for_date) {
            if (function_exists('maca_menulist_day_split_needs_booking_assignment')
                && maca_menulist_day_split_needs_booking_assignment($table_id, $date)) {
                maca_menulist_assign_bookings_to_split_slots_for_date($table_id, $date);
            }

            foreach (maca_menulist_get_table_split_slot_letters($table, $date) as $slot) {
                $row = maca_menulist_build_admin_floor_table_row($table, $date, $time_norm, $slot);
                $payload_tables[] = $row;
                $bucket = maca_menulist_floor_row_summary_bucket($row);
                if ($bucket === 'available') {
                    $available_count++;
                } elseif ($bucket === 'partial') {
                    $partial_count++;
                } else {
                    $full_count++;
                }
            }
            continue;
        }

        $row = maca_menulist_build_admin_floor_table_row($table, $date, $time_norm);
        $payload_tables[] = $row;
        $bucket = maca_menulist_floor_row_summary_bucket($row);
        if ($bucket === 'available') {
            $available_count++;
        } elseif ($bucket === 'partial') {
            $partial_count++;
        } else {
            $full_count++;
        }
    }

    return array(
        'closed' => false,
        'message' => '',
        'slot_duration' => $slot_duration,
        'max_bookings_per_table' => maca_menulist_admin_floor_max_bookings_per_table(),
        'tables' => $payload_tables,
        'queue' => maca_menulist_get_admin_floor_queue_bookings($date, $time_norm),
        'summary' => array(
            'total' => count($payload_tables),
            'available' => $available_count,
            'partial' => $partial_count,
            'full' => $full_count,
            'occupied' => $partial_count + $full_count,
        ),
        'day_occupancy' => maca_menulist_get_date_floor_day_occupancy($date),
    );
}

/**
 * Booking payload for floor overview cards and queue (drag-and-drop).
 *
 * @param object $booking Booking row.
 * @return array<string, mixed>
 */
function maca_menulist_build_floor_booking_payload($booking, $floor_time_norm = null) {
    $status = (string) $booking->status;

    $payload = array(
        'id' => (int) $booking->id,
        'guest_name' => (string) $booking->guest_name,
        'party_size' => (int) $booking->party_size,
        'booking_time' => substr((string) $booking->booking_time, 0, 5),
        'status' => $status,
        'status_label' => maca_menulist_get_booking_status_label($status),
        'move_targets' => array(),
    );

    if (in_array($status, array('pending', 'confirmed', 'waitlist'), true)) {
        $payload['move_targets'] = maca_menulist_get_booking_move_targets((int) $booking->id, $floor_time_norm);
    }

    return $payload;
}

/**
 * Pending and waitlist bookings for the floor queue at one time.
 *
 * @param string $date      Date Y-m-d.
 * @param string $time_norm Time HH:MM.
 * @return array<int, array<string, mixed>>
 */
function maca_menulist_get_admin_floor_queue_bookings($date, $time_norm) {
    global $wpdb;

    $date = sanitize_text_field($date);
    $time_norm = maca_menulist_normalize_time($time_norm);

    if ($date === '' || $time_norm === null) {
        return array();
    }

    maca_menulist_db_ensure_table_booking_tables();

    $bookings_table = maca_menulist_db_bookings_table();
    $duration = maca_menulist_table_booking_slot_duration();
    $statuses = array('pending', 'waitlist');
    $placeholders = implode(',', array_fill(0, count($statuses), '%s'));
    $params = array_merge(array($date), $statuses);

    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
    $rows = $wpdb->get_results(
        $wpdb->prepare(
            "SELECT * FROM $bookings_table WHERE booking_date = %s AND status IN ($placeholders) ORDER BY booking_time ASC, id ASC",
            ...$params
        )
    );

    $queue = array();

    foreach ($rows as $booking) {
        $status = (string) $booking->status;

        if ((string) $booking->booking_date !== $date) {
            continue;
        }

        if ($status === 'waitlist') {
            // Waitlist: visible all day; place at the time shown on the floor slider (e.g. 20:00 if a slot opens).
            $target_time = $time_norm;
            $at_requested_time = maca_menulist_booking_times_overlap($time_norm, $booking->booking_time, $duration);
        } else {
            if (
                (int) $booking->table_id > 0
                && maca_menulist_booking_times_overlap($time_norm, $booking->booking_time, $duration)
            ) {
                continue;
            }

            if (!maca_menulist_booking_times_overlap($time_norm, $booking->booking_time, $duration)) {
                continue;
            }

            $target_time = $time_norm;
            $at_requested_time = true;
        }

        $payload = maca_menulist_build_floor_booking_payload($booking, $target_time);
        $payload['requested_time'] = substr((string) $booking->booking_time, 0, 5);
        $payload['placement_time'] = substr($time_norm, 0, 5);
        $payload['at_requested_time'] = $at_requested_time;
        $payload['can_assign'] = !empty($payload['move_targets']);
        $queue[] = $payload;
    }

    return $queue;
}

/**
 * Tables a booking can be moved to at its date/time.
 *
 * @param int         $booking_id       Booking ID.
 * @param string|null $floor_time_norm  Optional floor slider time (HH:MM); uses booking time when omitted.
 * @return array<int, array{id: int, table_number: string, seats: int}>
 */
function maca_menulist_get_booking_move_targets($booking_id, $floor_time_norm = null) {
    $booking_id = absint($booking_id);
    $booking = maca_menulist_db_get_booking($booking_id);

    if (!$booking || !in_array($booking->status, array('pending', 'confirmed', 'waitlist'), true)) {
        return array();
    }

    $party_size = (int) $booking->party_size;
    $time = substr((string) $booking->booking_time, 0, 5);

    if ($floor_time_norm !== null && $floor_time_norm !== '') {
        $floor_norm = maca_menulist_normalize_time($floor_time_norm);
        if ($floor_norm !== null) {
            $time = substr($floor_norm, 0, 5);
        }
    }

    $date = (string) $booking->booking_date;
    $current_table_id = (int) $booking->table_id;
    $current_slot = isset($booking->split_slot)
        ? maca_menulist_normalize_split_slot((string) $booking->split_slot)
        : '';
    $targets = array();

    maca_menulist_db_ensure_table_booking_tables();

    foreach (maca_menulist_db_get_booking_tables(true) as $table) {
        $table_id = (int) $table->id;

        if (maca_menulist_table_is_splittable($table) && maca_menulist_is_table_split_for_date($table_id, $date)) {
            foreach (maca_menulist_get_table_split_slot_letters($table, $date) as $slot) {
                if ($table_id === $current_table_id && $slot === $current_slot) {
                    continue;
                }

                if (!maca_menulist_admin_table_can_accept_booking($table_id, $date, $time, $party_size, $booking_id, $slot)) {
                    continue;
                }

                $targets[] = array(
                    'id' => $table_id,
                    'split_slot' => $slot,
                    'table_number' => maca_menulist_format_split_table_number((string) $table->table_number, $slot),
                    'seats' => maca_menulist_table_split_slot_capacity($table, $slot, $date),
                );
            }
            continue;
        }

        if ($table_id === $current_table_id) {
            continue;
        }

        if ((int) $table->seats < $party_size) {
            continue;
        }

        if (!maca_menulist_admin_table_can_accept_booking($table_id, $date, $time, $party_size, $booking_id)) {
            continue;
        }

        $targets[] = array(
            'id' => $table_id,
            'split_slot' => '',
            'table_number' => (string) $table->table_number,
            'seats' => (int) $table->seats,
        );
    }

    usort(
        $targets,
        static function ($a, $b) {
            $seats_cmp = (int) $a['seats'] <=> (int) $b['seats'];
            if ($seats_cmp !== 0) {
                return $seats_cmp;
            }

            return strnatcasecmp((string) $a['table_number'], (string) $b['table_number']);
        }
    );

    return $targets;
}

/**
 * Move a booking to another table (staff).
 *
 * @param int    $booking_id       Booking ID.
 * @param int    $table_id         Target table ID.
 * @param string $split_slot       Optional split slot (a, b, …) when table is split for the day.
 * @param string $floor_time_norm  Optional floor slider time — validates and may sync booking time.
 * @return true|WP_Error
 */
function maca_menulist_move_booking_to_table($booking_id, $table_id, $split_slot = '', $floor_time_norm = '') {
    $booking_id = absint($booking_id);
    $table_id = absint($table_id);
    $split_slot = maca_menulist_normalize_split_slot((string) $split_slot);

    $booking = maca_menulist_db_get_booking($booking_id);
    if (!$booking) {
        return new WP_Error('not_found', __('Booking not found.', 'maca-restu-pro'));
    }

    if (!in_array($booking->status, array('pending', 'confirmed', 'waitlist'), true)) {
        return new WP_Error(
            'invalid_status',
            __('Only pending, waitlist or confirmed bookings can be moved to another table.', 'maca-restu-pro')
        );
    }

    if ($table_id <= 0) {
        return new WP_Error('no_table', __('Select a table.', 'maca-restu-pro'));
    }

    $current_slot = isset($booking->split_slot)
        ? maca_menulist_normalize_split_slot((string) $booking->split_slot)
        : '';

    if ((int) $booking->table_id === $table_id && $split_slot === $current_slot) {
        return new WP_Error('same_table', __('The booking is already assigned to this table.', 'maca-restu-pro'));
    }

    $table = maca_menulist_db_get_booking_table($table_id);
    if (!$table || !(int) $table->is_active) {
        return new WP_Error('invalid_table', __('Table not found.', 'maca-restu-pro'));
    }

    $was_waitlist = ($booking->status === 'waitlist');
    $time = substr((string) $booking->booking_time, 0, 5);
    $sync_time_norm = null;

    if ($floor_time_norm !== '') {
        $sync_time_norm = maca_menulist_normalize_time($floor_time_norm);
        if ($sync_time_norm !== null) {
            $time = substr($sync_time_norm, 0, 5);
        }
    }

    $target_slot = $split_slot;
    if (maca_menulist_is_table_split_for_date($table_id, $booking->booking_date)) {
        if ($target_slot === '') {
            return new WP_Error('split_slot_required', __('Välj en del av bordet (t.ex. 8a).', 'maca-restu-pro'));
        }
    } else {
        $target_slot = '';
    }

    $seat_limit = (int) $table->seats;
    if ($target_slot !== '' && maca_menulist_is_table_split_for_date($table_id, $booking->booking_date)) {
        $seat_limit = maca_menulist_table_split_slot_capacity($table, $target_slot, $booking->booking_date);
    }

    if ($seat_limit < (int) $booking->party_size) {
        return new WP_Error(
            'too_small',
            __('The selected table does not have enough seats for this party.', 'maca-restu-pro')
        );
    }

    if (!maca_menulist_admin_table_can_accept_booking($table_id, $booking->booking_date, $time, (int) $booking->party_size, $booking_id, $target_slot)) {
        $others = 0;
        foreach (maca_menulist_get_table_bookings_at_time($table_id, $booking->booking_date, $time) as $row) {
            if ((int) $row->id !== $booking_id) {
                $others++;
            }
        }

        $table_row = maca_menulist_db_get_booking_table($table_id);
        $max_bookings = $table_row ? maca_menulist_table_max_share_bookings($table_row) : maca_menulist_admin_floor_max_bookings_per_table();

        if ($others >= $max_bookings) {
            return new WP_Error(
                'max_bookings',
                sprintf(
                    /* translators: %d: max bookings per table */
                    __('Max %d bokningar per bord vid samma tid.', 'maca-restu-pro'),
                    $max_bookings
                )
            );
        }

        if ($table_row && maca_menulist_table_is_splittable($table_row)) {
            $capacities = maca_menulist_get_table_split_capacities($table_row, $booking->booking_date);
            if (!maca_menulist_splittable_bookings_fit_capacities($capacities, maca_menulist_get_table_bookings_at_time($table_id, $booking->booking_date, $time), (int) $booking->party_size, $booking_id)) {
                return new WP_Error(
                    'split_party_too_large',
                    sprintf(
                        /* translators: 1: max guests in largest slot, 2: layout label e.g. 4+2 */
                        __('Bokningen passar inte i någon ledig del av bordet (%2$s). Största del: %1$d platser.', 'maca-restu-pro'),
                        maca_menulist_table_split_max_party_size($table_row, $booking->booking_date),
                        maca_menulist_table_split_layout_label($table_row, $booking->booking_date)
                    )
                );
            }
        }

        return new WP_Error(
            'not_enough_seats',
            sprintf(
                /* translators: %d: party size */
                __('Det finns inte plats för %d gäster till på bordet.', 'maca-restu-pro'),
                (int) $booking->party_size
            )
        );
    }

    if (!maca_menulist_db_update_booking_table($booking_id, $table_id, $target_slot)) {
        return new WP_Error('db_error', __('Could not move the booking.', 'maca-restu-pro'));
    }

    $before_log = array(
        'table_id' => (int) $booking->table_id,
        'split_slot' => $current_slot,
    );
    $after_log = array(
        'table_id' => $table_id,
        'split_slot' => $target_slot,
    );

    if ($sync_time_norm !== null) {
        $previous_time = maca_menulist_normalize_time((string) $booking->booking_time);
        if ($previous_time !== null && $previous_time !== $sync_time_norm) {
            maca_menulist_db_update_booking_fields(
                $booking_id,
                array(
                    'booking_time' => $sync_time_norm,
                )
            );
            $before_log['booking_time'] = substr($previous_time, 0, 5);
            $after_log['booking_time'] = substr($sync_time_norm, 0, 5);
        }
    }

    if ($was_waitlist) {
        maca_menulist_db_update_booking_status($booking_id, 'pending', $table_id);
        $before_log['status'] = 'waitlist';
        $after_log['status'] = 'pending';
    }

    if (function_exists('maca_menulist_booking_log_field_updates')) {
        maca_menulist_booking_log_field_updates(
            $booking_id,
            $before_log,
            $after_log,
            $was_waitlist ? 'waitlist_placed' : 'moved'
        );
    }

    return true;
}

/**
 * Check if two booking intervals overlap.
 *
 * @param string $time_a   Start time HH:MM or HH:MM:SS.
 * @param string $time_b   Other start time.
 * @param int    $duration Duration in minutes.
 * @return bool
 */
function maca_menulist_booking_times_overlap($time_a, $time_b, $duration) {
    $start_a = strtotime('1970-01-01 ' . substr($time_a, 0, 8));
    $start_b = strtotime('1970-01-01 ' . substr($time_b, 0, 8));
    $end_a = $start_a + ($duration * 60);
    $end_b = $start_b + ($duration * 60);

    return ($start_a < $end_b && $start_b < $end_a);
}

/**
 * Whether a table is free at a given date/time.
 *
 * @param int    $table_id   Table ID.
 * @param string $date       Date Y-m-d.
 * @param string $time       Time HH:MM.
 * @param int    $exclude_id Booking ID to exclude.
 * @return bool
 */
function maca_menulist_is_table_available($table_id, $date, $time, $exclude_id = 0) {
    $table_id = absint($table_id);
    $exclude_id = absint($exclude_id);
    $duration = maca_menulist_table_booking_slot_duration();

    $existing = maca_menulist_db_get_table_bookings_for_date($date, $table_id);

    foreach ($existing as $booking) {
        if ($exclude_id > 0 && (int) $booking->id === $exclude_id) {
            continue;
        }

        if (maca_menulist_booking_times_overlap($time, $booking->booking_time, $duration)) {
            return false;
        }
    }

    return true;
}

/**
 * Get available tables for date, time and party size.
 *
 * @param string   $date       Date Y-m-d.
 * @param string   $time       Time HH:MM.
 * @param int      $party_size Party size.
 * @param int|null $exclude_id Booking ID to exclude.
 * @return array<int, object>
 */
function maca_menulist_get_available_tables($date, $time, $party_size = 2, $exclude_id = null) {
    $party_size = max(1, (int) $party_size);
    $exclude_id = $exclude_id !== null ? absint($exclude_id) : 0;

    if (!maca_menulist_is_date_bookable($date)) {
        return array();
    }

    $hours = maca_menulist_get_booking_hours_for_date($date);
    if (!maca_menulist_hours_are_bookable($hours)) {
        return array();
    }

    $time_norm = maca_menulist_normalize_time($time);
    if ($time_norm === null) {
        return array();
    }

    if (!maca_menulist_is_booking_start_in_hours_windows($date, $time_norm, maca_menulist_get_hours_windows($hours))) {
        return array();
    }

    maca_menulist_db_ensure_table_booking_tables();
    $tables = maca_menulist_db_get_booking_tables(true);
    $available = array();

    foreach ($tables as $table) {
        if ((int) $table->seats < $party_size) {
            continue;
        }

        if (maca_menulist_is_table_available((int) $table->id, $date, $time_norm, $exclude_id)) {
            $available[] = $table;
        }
    }

    usort(
        $available,
        static function ($a, $b) {
            if (maca_menulist_table_booking_uses_best_fit_assignment()) {
                $seats_cmp = (int) $a->seats <=> (int) $b->seats;
                if ($seats_cmp !== 0) {
                    return $seats_cmp;
                }
            }

            $sort_cmp = (int) $a->sort_order <=> (int) $b->sort_order;
            if ($sort_cmp !== 0) {
                return $sort_cmp;
            }

            return strnatcasecmp((string) $a->table_number, (string) $b->table_number);
        }
    );

    return $available;
}

/**
 * Pick the smallest available table that fits the party (best-fit).
 *
 * @param string   $date       Date Y-m-d.
 * @param string   $time       Time HH:MM.
 * @param int      $party_size Party size.
 * @param int|null $exclude_id Booking ID to exclude.
 * @return object|null
 */
function maca_menulist_pick_best_available_table($date, $time, $party_size = 2, $exclude_id = null) {
    $available = maca_menulist_get_available_tables($date, $time, $party_size, $exclude_id);

    return !empty($available) ? $available[0] : null;
}

/**
 * Placeholder email when staff books without a guest address.
 *
 * @return string
 */
function maca_menulist_staff_booking_placeholder_email() {
    $host = wp_parse_url(home_url(), PHP_URL_HOST);
    $host = is_string($host) && $host !== '' ? $host : 'localhost';

    return 'bokning@' . $host;
}

/**
 * Tables / split parts staff can assign at a date and time.
 *
 * @param string $date       Date Y-m-d.
 * @param string $time       Time HH:MM.
 * @param int    $party_size Party size.
 * @return array<int, array{table_id: int, split_slot: string, label: string}>
 */
function maca_menulist_get_staff_booking_table_options($date, $time, $party_size = 2) {
    $party_size = max(1, (int) $party_size);
    $time_norm = maca_menulist_normalize_time($time);

    if ($time_norm === null || !maca_menulist_is_date_bookable($date)) {
        return array();
    }

    $status = maca_menulist_get_admin_booking_floor_status($date, $time_norm);
    if (!empty($status['closed'])) {
        return array();
    }

    $options = array();

    foreach ($status['tables'] as $row) {
        $table_id = (int) $row['id'];
        $split_slot = isset($row['split_slot']) ? maca_menulist_normalize_split_slot((string) $row['split_slot']) : '';

        if (!maca_menulist_admin_table_can_accept_booking($table_id, $date, $time_norm, $party_size, 0, $split_slot)) {
            continue;
        }

        $options[] = array(
            'table_id'   => $table_id,
            'split_slot' => $split_slot,
            'label'      => (string) $row['table_number'],
        );
    }

    return $options;
}

/**
 * Validate staff manual booking input.
 *
 * @param array<string, mixed> $data Form data.
 * @return true|WP_Error
 */
function maca_menulist_validate_staff_booking_submission($data) {
    $date = isset($data['booking_date']) ? sanitize_text_field($data['booking_date']) : '';
    $time = isset($data['booking_time']) ? sanitize_text_field($data['booking_time']) : '';
    $party_size = isset($data['party_size']) ? max(0, (int) $data['party_size']) : 0;
    $guest_name = isset($data['guest_name']) ? sanitize_text_field($data['guest_name']) : '';
    $guest_phone = isset($data['guest_phone']) ? sanitize_text_field($data['guest_phone']) : '';
    $guest_email = isset($data['guest_email']) ? sanitize_email($data['guest_email']) : '';

    if (
        $date === ''
        || $time === ''
        || maca_menulist_normalize_time($time) === null
        || $party_size < 1
        || $guest_name === ''
        || $guest_phone === ''
    ) {
        return new WP_Error('required_fields', __('Fyll i namn, telefon, datum, tid och antal gäster.', 'maca-restu-pro'));
    }

    if ($guest_email !== '' && !is_email($guest_email)) {
        return new WP_Error('invalid_email', __('Ogiltig e-postadress.', 'maca-restu-pro'));
    }

    if (!maca_menulist_is_date_bookable($date)) {
        return new WP_Error('date_unavailable', __('Bokningar är inte tillgängliga detta datum.', 'maca-restu-pro'));
    }

    return true;
}

/**
 * Create a confirmed booking from the admin (walk-in / phone).
 *
 * @param array<string, mixed> $data Booking data.
 * @return int|WP_Error Booking ID.
 */
function maca_menulist_create_staff_booking($data) {
    if (!empty($data['waitlist'])) {
        return maca_menulist_create_waitlist_booking($data, 'staff');
    }

    $validation = maca_menulist_validate_staff_booking_submission($data);
    if (is_wp_error($validation)) {
        return $validation;
    }

    $date = sanitize_text_field($data['booking_date']);
    $time = sanitize_text_field($data['booking_time']);
    $time_norm = maca_menulist_normalize_time($time);
    $party_size = max(1, (int) $data['party_size']);
    $table_id = isset($data['table_id']) ? absint($data['table_id']) : 0;
    $split_slot = isset($data['split_slot']) ? maca_menulist_normalize_split_slot((string) $data['split_slot']) : '';
    $guest_name = sanitize_text_field($data['guest_name']);
    $guest_phone = sanitize_text_field($data['guest_phone']);
    $guest_email = isset($data['guest_email']) ? sanitize_email($data['guest_email']) : '';
    $guest_note = isset($data['guest_note']) ? sanitize_textarea_field($data['guest_note']) : '';
    $send_email = !empty($data['send_email']);

    if ($guest_email === '' || !is_email($guest_email)) {
        $guest_email = maca_menulist_staff_booking_placeholder_email();
    }

    if ($table_id <= 0) {
        $options = maca_menulist_get_staff_booking_table_options($date, $time_norm, $party_size);
        if (empty($options)) {
            return new WP_Error(
                'no_table',
                maca_menulist_table_booking_get_unavailable_message($date, $party_size)
            );
        }

        $table_id = (int) $options[0]['table_id'];
        $split_slot = (string) $options[0]['split_slot'];
    }

    $table = maca_menulist_db_get_booking_table($table_id);
    if (!$table || !(int) $table->is_active) {
        return new WP_Error('invalid_table', __('Bordet hittades inte.', 'maca-restu-pro'));
    }

    if (maca_menulist_is_table_split_for_date($table_id, $date)) {
        if ($split_slot === '') {
            return new WP_Error('split_slot_required', __('Välj en del av bordet (t.ex. 8a).', 'maca-restu-pro'));
        }
    } else {
        $split_slot = '';
    }

    if (!maca_menulist_admin_table_can_accept_booking($table_id, $date, $time_norm, $party_size, 0, $split_slot)) {
        return new WP_Error('table_taken', __('Det valda bordet har inte plats vid denna tid.', 'maca-restu-pro'));
    }

    $result = maca_menulist_db_insert_booking(
        array(
            'table_id'      => $table_id,
            'booking_date'  => $date,
            'booking_time'  => $time,
            'party_size'    => $party_size,
            'guest_name'    => $guest_name,
            'guest_email'   => $guest_email,
            'guest_phone'   => $guest_phone,
            'guest_note'    => $guest_note,
        )
    );

    if (!$result) {
        return new WP_Error('save_failed', __('Kunde inte spara bokningen.', 'maca-restu-pro'));
    }

    $booking_id = (int) $result['id'];
    maca_menulist_db_update_booking_table($booking_id, $table_id, $split_slot);
    maca_menulist_db_update_booking_status($booking_id, 'confirmed', $table_id);

    if ($send_email && is_email($guest_email) && $guest_email !== maca_menulist_staff_booking_placeholder_email()) {
        maca_menulist_send_booking_email($booking_id, 'confirmed');
    }

    if (function_exists('maca_menulist_booking_log_created')) {
        maca_menulist_booking_log_created($booking_id, 'staff');
    }

    /**
     * Fires after staff creates a table reservation in admin.
     *
     * @param int $booking_id Booking ID.
     */
    do_action('maca_menulist_booking_created', $booking_id);

    return $booking_id;
}

/**
 * Validate that a date/time is within bookable opening hours.
 *
 * @param string $date Date Y-m-d.
 * @param string $time Time HH:MM or HH:MM:SS.
 * @return string|WP_Error Normalized time HH:MM:SS.
 */
function maca_menulist_validate_booking_time_slot($date, $time) {
    $date = sanitize_text_field($date);
    $time_norm = maca_menulist_normalize_time($time);

    if ($date === '' || $time_norm === null) {
        return new WP_Error('invalid_datetime', __('Ogiltigt datum eller tid.', 'maca-restu-pro'));
    }

    if (!maca_menulist_is_date_bookable($date)) {
        return new WP_Error('date_unavailable', __('Bokningar är inte tillgängliga detta datum.', 'maca-restu-pro'));
    }

    $hours = maca_menulist_get_booking_hours_for_date($date);
    if (!maca_menulist_hours_are_bookable($hours)) {
        return new WP_Error('date_unavailable', maca_menulist_table_booking_get_unavailable_message($date, 1));
    }

    if (!maca_menulist_is_booking_start_in_hours_windows($date, $time_norm, maca_menulist_get_hours_windows($hours))) {
        return new WP_Error('outside_hours', __('Vald tid ligger utanför öppettiderna.', 'maca-restu-pro'));
    }

    return $time_norm;
}

/**
 * Validate table capacity when booking date/time/party size changes.
 *
 * @param object $booking    Booking row.
 * @param string $date       Date Y-m-d.
 * @param string $time_norm  Time HH:MM:SS.
 * @param int    $party_size Party size.
 * @return true|WP_Error
 */
function maca_menulist_validate_booking_table_fit($booking, $date, $time_norm, $party_size) {
    $table_id = (int) $booking->table_id;
    if ($table_id <= 0) {
        return true;
    }

    $time = substr($time_norm, 0, 5);
    $table = maca_menulist_db_get_booking_table($table_id);
    if (!$table || !(int) $table->is_active) {
        return new WP_Error('invalid_table', __('Table not found.', 'maca-restu-pro'));
    }

    $split_slot = isset($booking->split_slot)
        ? maca_menulist_normalize_split_slot((string) $booking->split_slot)
        : '';

    if (maca_menulist_is_table_split_for_date($table_id, $date) && $split_slot === '') {
        return new WP_Error('split_slot_required', __('Välj en del av bordet (t.ex. 8a).', 'maca-restu-pro'));
    }

    $seat_limit = (int) $table->seats;
    if ($split_slot !== '' && maca_menulist_is_table_split_for_date($table_id, $date)) {
        $seat_limit = maca_menulist_table_split_slot_capacity($table, $split_slot, $date);
    }

    if ($party_size > $seat_limit) {
        return new WP_Error(
            'too_large',
            sprintf(
                /* translators: %d: max seats on table or split part */
                __('Max %d gäster på detta bord.', 'maca-restu-pro'),
                $seat_limit
            )
        );
    }

    if (!maca_menulist_admin_table_can_accept_booking($table_id, $date, $time, $party_size, (int) $booking->id, $split_slot)) {
        return new WP_Error(
            'not_enough_seats',
            sprintf(
                /* translators: %d: party size */
                __('Det finns inte plats för %d gäster på bordet vid denna tid.', 'maca-restu-pro'),
                $party_size
            )
        );
    }

    return true;
}

/**
 * Update booking guest details, date, time and party size (staff).
 *
 * @param int                  $booking_id Booking ID.
 * @param array<string, mixed> $data       Form data.
 * @return true|WP_Error
 */
function maca_menulist_update_booking_details($booking_id, $data) {
    $booking_id = absint($booking_id);

    $booking = maca_menulist_db_get_booking($booking_id);
    if (!$booking) {
        return new WP_Error('not_found', __('Booking not found.', 'maca-restu-pro'));
    }

    if (!in_array($booking->status, array('pending', 'confirmed', 'waitlist'), true)) {
        return new WP_Error(
            'invalid_status',
            __('Only pending, waitlist or confirmed bookings can be updated.', 'maca-restu-pro')
        );
    }

    $guest_name = isset($data['guest_name']) ? sanitize_text_field($data['guest_name']) : '';
    $guest_phone = isset($data['guest_phone']) ? sanitize_text_field($data['guest_phone']) : '';
    $guest_email = isset($data['guest_email']) ? sanitize_email($data['guest_email']) : '';
    $guest_note = isset($data['guest_note']) ? sanitize_textarea_field($data['guest_note']) : '';
    $booking_date = isset($data['booking_date']) ? sanitize_text_field($data['booking_date']) : '';
    $booking_time = isset($data['booking_time']) ? sanitize_text_field($data['booking_time']) : '';
    $party_size = isset($data['party_size']) ? max(1, (int) $data['party_size']) : 0;

    if ($guest_name === '') {
        return new WP_Error('required_name', __('Namn krävs.', 'maca-restu-pro'));
    }

    if ($guest_phone === '') {
        return new WP_Error('required_phone', __('Telefon krävs.', 'maca-restu-pro'));
    }

    if ($guest_email !== '' && !is_email($guest_email)) {
        return new WP_Error('invalid_email', __('Ogiltig e-postadress.', 'maca-restu-pro'));
    }

    if ($party_size < 1) {
        return new WP_Error('required_party', __('Antal gäster krävs.', 'maca-restu-pro'));
    }

    if ($booking->status === 'waitlist') {
        $time_check = maca_menulist_validate_waitlist_time_slot($booking_date, $booking_time);
    } else {
        $time_check = maca_menulist_validate_booking_time_slot($booking_date, $booking_time);
    }

    if (is_wp_error($time_check)) {
        return $time_check;
    }

    $time_norm = $time_check;

    if ($guest_email === '' || !is_email($guest_email)) {
        $guest_email = maca_menulist_staff_booking_placeholder_email();
    }

    $current_note = isset($booking->guest_note) ? (string) $booking->guest_note : '';
    $current_time = maca_menulist_normalize_time((string) $booking->booking_time);

    $unchanged = (
        $guest_name === (string) $booking->guest_name
        && $guest_phone === (string) $booking->guest_phone
        && $guest_email === (string) $booking->guest_email
        && $guest_note === $current_note
        && $booking_date === (string) $booking->booking_date
        && $time_norm === $current_time
        && $party_size === (int) $booking->party_size
    );

    if ($unchanged) {
        return new WP_Error('unchanged', __('Inga ändringar att spara.', 'maca-restu-pro'));
    }

    if ($booking->status !== 'waitlist') {
        $table_fit = maca_menulist_validate_booking_table_fit($booking, $booking_date, $time_norm, $party_size);
        if (is_wp_error($table_fit)) {
            return $table_fit;
        }
    }

    $saved = maca_menulist_db_update_booking_fields(
        $booking_id,
        array(
            'guest_name' => $guest_name,
            'guest_phone' => $guest_phone,
            'guest_email' => $guest_email,
            'guest_note' => $guest_note,
            'booking_date' => $booking_date,
            'booking_time' => $time_norm,
            'party_size' => $party_size,
        )
    );

    if (!$saved) {
        return new WP_Error('save_failed', __('Kunde inte spara bokningen.', 'maca-restu-pro'));
    }

    if (function_exists('maca_menulist_booking_log_field_updates')) {
        maca_menulist_booking_log_field_updates(
            $booking_id,
            array(
                'guest_name' => $booking->guest_name,
                'guest_phone' => $booking->guest_phone,
                'guest_email' => $booking->guest_email,
                'guest_note' => $current_note,
                'booking_date' => $booking->booking_date,
                'booking_time' => $current_time,
                'party_size' => (int) $booking->party_size,
            ),
            array(
                'guest_name' => $guest_name,
                'guest_phone' => $guest_phone,
                'guest_email' => $guest_email,
                'guest_note' => $guest_note,
                'booking_date' => $booking_date,
                'booking_time' => $time_norm,
                'party_size' => $party_size,
            ),
            'updated'
        );
    }

    return true;
}

/**
 * Update party size on a pending or confirmed booking.
 *
 * @param int $booking_id Booking ID.
 * @param int $party_size New party size.
 * @return true|WP_Error
 */
function maca_menulist_update_booking_party_size($booking_id, $party_size) {
    $booking = maca_menulist_db_get_booking($booking_id);
    if (!$booking) {
        return new WP_Error('not_found', __('Booking not found.', 'maca-restu-pro'));
    }

    return maca_menulist_update_booking_details(
        $booking_id,
        array(
            'guest_name' => $booking->guest_name,
            'guest_phone' => $booking->guest_phone,
            'guest_email' => $booking->guest_email,
            'guest_note' => isset($booking->guest_note) ? $booking->guest_note : '',
            'booking_date' => $booking->booking_date,
            'booking_time' => $booking->booking_time,
            'party_size' => $party_size,
        )
    );
}

/**
 * Total seat capacity across all active booking tables.
 *
 * @return int
 */
function maca_menulist_get_total_booking_seat_capacity() {
    maca_menulist_db_ensure_table_booking_tables();

    $total = 0;

    foreach (maca_menulist_db_get_booking_tables(true) as $table) {
        $total += (int) $table->seats;
    }

    return $total;
}

/**
 * Maximum free bookable seats on a date (best time slot, seat-matrix logic).
 *
 * @param string $date Date Y-m-d.
 * @return int
 */
function maca_menulist_get_date_available_seats($date) {
    if (!maca_menulist_is_date_bookable($date) || !maca_menulist_table_booking_has_active_tables()) {
        return 0;
    }

    if (!function_exists('maca_menulist_get_date_seat_utilization_summary')) {
        return 0;
    }

    $summary = maca_menulist_get_date_seat_utilization_summary($date);

    return (int) ($summary['max_free_seats'] ?? 0);
}

/**
 * Day occupancy for admin floor overview (confirmed + pending vs peak capacity).
 *
 * @param string $date Date Y-m-d.
 * @return array{percent: int, booked_seats: int, peak_capacity: int, level: string}
 */
function maca_menulist_get_date_floor_day_occupancy($date) {
    $empty = array(
        'percent'       => 0,
        'booked_seats'  => 0,
        'peak_capacity' => 0,
        'level'         => 'low',
    );

    if (!function_exists('maca_menulist_get_date_seat_utilization_summary')) {
        return $empty;
    }

    $util = maca_menulist_get_date_seat_utilization_summary($date);
    $peak_capacity = (int) ($util['peak_capacity'] ?? 0);

    if ($peak_capacity <= 0) {
        return $empty;
    }

    $percent = (int) ($util['daily_occupancy_percent'] ?? 0);
    $level = 'low';

    if ($percent >= 90) {
        $level = 'high';
    } elseif ($percent >= 70) {
        $level = 'medium';
    }

    return array(
        'percent'       => $percent,
        'booked_seats'  => (int) ($util['booked_seats'] ?? 0),
        'peak_capacity' => $peak_capacity,
        'level'         => $level,
    );
}

/**
 * Day occupancy with a display label for dashboards.
 *
 * @param string $date Date Y-m-d.
 * @return array{percent: int, booked_seats: int, peak_capacity: int, level: string, label: string}
 */
function maca_menulist_get_date_day_occupancy_display($date) {
    $occ = maca_menulist_get_date_floor_day_occupancy($date);

    if ((int) ($occ['peak_capacity'] ?? 0) <= 0) {
        $occ['label'] = '—';
        return $occ;
    }

    $occ['label'] = sprintf(
        /* translators: 1: occupancy percent, 2: booked guests, 3: peak capacity */
        __('%1$d%% (%2$d / %3$d)', 'maca-restu-pro'),
        (int) $occ['percent'],
        (int) $occ['booked_seats'],
        (int) $occ['peak_capacity']
    );

    return $occ;
}

/**
 * Availability status label for a date.
 *
 * @param string     $date Date Y-m-d.
 * @param array|null $util_summary Optional precomputed seat utilization from maca_menulist_get_date_seat_utilization_summary().
 * @return array{key: string, label: string}
 */
function maca_menulist_get_date_availability_status($date, $util_summary = null) {
    $date = sanitize_text_field($date);

    if (!maca_menulist_table_booking_has_active_tables()) {
        return array(
            'key' => 'no_tables',
            'label' => __('No tables configured', 'maca-restu-pro'),
        );
    }

    $exception = maca_menulist_get_booking_exception_for_date($date);

    if ($exception && $exception->exception_type === 'closed') {
        return array(
            'key' => 'closed',
            'label' => __('Closed', 'maca-restu-pro'),
        );
    }

    if ($exception && $exception->exception_type === 'fully_booked') {
        return array(
            'key' => 'fully_booked',
            'label' => __('Fully booked', 'maca-restu-pro'),
        );
    }

    $hours = maca_menulist_get_booking_hours_for_date($date);

    if (empty($hours['bookable'])) {
        return array(
            'key' => 'closed',
            'label' => __('Closed', 'maca-restu-pro'),
        );
    }

    $max_free = 0;
    if (is_array($util_summary)) {
        $max_free = (int) ($util_summary['max_free_seats'] ?? 0);
    } else {
        $max_free = maca_menulist_get_date_available_seats($date);
    }

    if ($max_free <= 0) {
        return array(
            'key' => 'full',
            'label' => __('Fully booked', 'maca-restu-pro'),
        );
    }

    return array(
        'key' => 'open',
        'label' => __('Open', 'maca-restu-pro'),
    );
}

/**
 * Overview rows for admin availability list.
 *
 * @return array<int, array<string, mixed>>
 */
function maca_menulist_get_booking_availability_overview() {
    $today = maca_menulist_booking_now()->setTime(0, 0);
    $advance_days = maca_menulist_table_booking_advance_days();
    $end = $today->modify('+' . $advance_days . ' days');
    $rows = array();

    for ($current = $today; $current <= $end; $current = $current->modify('+1 day')) {
        $date = $current->format('Y-m-d');
        $stats = maca_menulist_db_get_booking_day_stats($date);
        $util = function_exists('maca_menulist_get_date_seat_utilization_summary')
            ? maca_menulist_get_date_seat_utilization_summary($date)
            : array(
                'peak_capacity' => 0,
                'remaining_seats' => 0,
                'max_free_seats' => maca_menulist_get_date_available_seats($date),
                'daily_occupancy_percent' => 0,
                'sitting_count' => 0,
                'seats_per_sitting' => 0,
            );
        $status = maca_menulist_get_date_availability_status($date, $util);

        $rows[] = array(
            'date' => $date,
            'date_label' => wp_date(get_option('date_format'), $current->getTimestamp()),
            'weekday' => wp_date('l', $current->getTimestamp()),
            'status' => $status,
            'peak_capacity' => (int) $util['peak_capacity'],
            'seats_per_sitting' => (int) ($util['seats_per_sitting'] ?? 0),
            'sitting_count' => (int) ($util['sitting_count'] ?? 0),
            'available_seats' => (int) ($util['remaining_seats'] ?? 0),
            'max_free_slot' => (int) ($util['max_free_seats'] ?? 0),
            'occupancy_percent' => (int) ($util['daily_occupancy_percent'] ?? 0),
            'confirmed_guests' => (int) $stats['confirmed_guests'],
            'pending_guests' => (int) $stats['pending_guests'],
            'waitlist_guests' => (int) $stats['waitlist_guests'],
        );
    }

    return $rows;
}

/**
 * Restaurant display name from Settings → Restaurant (menu footer).
 *
 * Used in guest emails, booking copy, and payment confirmations.
 *
 * @return string Plain text (decoded).
 */
function maca_menulist_get_restaurant_name_from_settings() {
    $name = get_option('maca_menulist_menu_footer_restaurant_name', '');

    if (!is_string($name)) {
        $name = '';
    }

    $name = trim(wp_specialchars_decode($name, ENT_QUOTES));

    if ($name !== '') {
        return $name;
    }

    return wp_specialchars_decode(get_bloginfo('name'), ENT_QUOTES);
}

/**
 * Restaurant name for emails and guest-facing copy (alias).
 *
 * @return string
 */
function maca_menulist_get_booking_restaurant_name() {
    return maca_menulist_get_restaurant_name_from_settings();
}

/**
 * Plain sender name for wp_mail / PHPMailer (not MIME-encoded).
 *
 * @return string
 */
function maca_menulist_get_restaurant_email_from_name_plain() {
    return maca_menulist_get_restaurant_name_from_settings();
}

/**
 * Menu footer contact email (general restaurant contact, not table-booking-specific).
 *
 * @return string
 */
function maca_menulist_get_menu_footer_contact_email() {
    $email = get_option('maca_menulist_menu_footer_email', '');

    if ($email !== '' && is_email($email)) {
        return $email;
    }

    return (string) get_option('admin_email');
}

/**
 * Table Reservation notification email (sender + restaurant copies).
 *
 * Set under Table Reservations → Table settings. Falls back to menu footer email.
 *
 * @return string
 */
function maca_menulist_get_table_booking_notification_email() {
    $email = get_option('maca_menulist_table_booking_notification_email', '');

    if ($email !== '' && is_email($email)) {
        return $email;
    }

    return maca_menulist_get_menu_footer_contact_email();
}

/**
 * Restaurant notification email for table reservations.
 *
 * @return string
 */
function maca_menulist_get_booking_restaurant_email() {
    return maca_menulist_get_table_booking_notification_email();
}

/**
 * Restaurant name formatted for email From / Reply-To headers.
 *
 * @param string $name Optional override.
 * @return string
 */
function maca_menulist_format_restaurant_email_from_name($name = '') {
    if ($name === '') {
        $name = maca_menulist_get_restaurant_email_from_name_plain();
    } else {
        $name = trim(wp_specialchars_decode((string) $name, ENT_QUOTES));
    }

    $name = str_replace(array("\r", "\n"), '', $name);

    if ($name === '') {
        $name = maca_menulist_get_restaurant_email_from_name_plain();
    }

    if (function_exists('mb_encode_mimeheader') && preg_match('/[^\x20-\x7E]/', $name)) {
        return mb_encode_mimeheader($name, 'UTF-8', 'B', "\r\n");
    }

    return $name;
}

/**
 * Email headers with restaurant name as sender (From + Reply-To).
 *
 * @param string             $from_email      Sender address.
 * @param array<int, string> $extra_headers   Optional additional headers.
 * @return array<int, string>
 */
function maca_menulist_build_email_headers($from_email, $extra_headers = array()) {
    $content_type = 'text/plain; charset=UTF-8';
    $filtered_extra = array();

    if (is_array($extra_headers)) {
        foreach ($extra_headers as $header) {
            if (!is_string($header) || $header === '') {
                continue;
            }
            if (stripos($header, 'Content-Type:') === 0) {
                $content_type = trim(substr($header, 13));
                continue;
            }
            if (stripos($header, 'From:') === 0 || stripos($header, 'Reply-To:') === 0) {
                continue;
            }
            $filtered_extra[] = $header;
        }
    }

    $headers = array('Content-Type: ' . $content_type);
    $headers = array_merge($headers, $filtered_extra);

    if (!is_email($from_email)) {
        return $headers;
    }

    $from_name = maca_menulist_format_restaurant_email_from_name();

    $headers[] = sprintf('From: %s <%s>', $from_name, $from_email);
    $headers[] = sprintf('Reply-To: %s <%s>', $from_name, $from_email);

    return $headers;
}

/**
 * Email headers using menu footer contact email.
 *
 * @param array<int, string> $extra_headers Optional additional headers.
 * @return array<int, string>
 */
function maca_menulist_build_restaurant_email_headers($extra_headers = array()) {
    return maca_menulist_build_email_headers(maca_menulist_get_menu_footer_contact_email(), $extra_headers);
}

/**
 * Email headers for table reservation notifications.
 *
 * @param array<int, string> $extra_headers Optional additional headers.
 * @return array<int, string>
 */
function maca_menulist_build_table_booking_email_headers($extra_headers = array()) {
    return maca_menulist_build_email_headers(maca_menulist_get_table_booking_notification_email(), $extra_headers);
}

/**
 * Send via wp_mail (uses site SMTP plugins) with a specific From address.
 *
 * @param string|array       $to            Recipient(s).
 * @param string             $subject       Email subject.
 * @param string             $message       Email body.
 * @param string             $from_email    Sender email.
 * @param array<int, string> $extra_headers Optional extra headers.
 * @return bool
 */
function maca_menulist_wp_mail_with_sender($to, $subject, $message, $from_email, $extra_headers = array()) {
    $from_email = is_email($from_email) ? $from_email : (string) get_option('admin_email');
    $from_name_plain = maca_menulist_get_restaurant_email_from_name_plain();
    $headers         = maca_menulist_build_email_headers($from_email, $extra_headers);

    $from_email_filter = static function () use ($from_email) {
        return $from_email;
    };
    $from_name_filter = static function () use ($from_name_plain) {
        return $from_name_plain;
    };
    $phpmailer_init = static function ($phpmailer) use ($from_email, $from_name_plain) {
        if (!is_object($phpmailer) || !method_exists($phpmailer, 'setFrom')) {
            return;
        }

        try {
            $phpmailer->setFrom($from_email, $from_name_plain, false);
        } catch (Exception $e) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
            /* SMTP plugins may reject From; wp_mail_from* filters still apply. */
        }
    };

    add_filter('wp_mail_from', $from_email_filter, 9999);
    add_filter('wp_mail_from_name', $from_name_filter, 9999);
    add_action('phpmailer_init', $phpmailer_init, 9999);

    $sent = wp_mail($to, $subject, $message, $headers);

    remove_action('phpmailer_init', $phpmailer_init, 9999);
    remove_filter('wp_mail_from', $from_email_filter, 9999);
    remove_filter('wp_mail_from_name', $from_name_filter, 9999);

    return (bool) $sent;
}

/**
 * Send email with menu footer contact as sender (orders and general mail).
 *
 * @param string|array       $to            Recipient(s).
 * @param string             $subject       Email subject.
 * @param string             $message       Email body.
 * @param array<int, string> $extra_headers Optional extra headers.
 * @return bool
 */
function maca_menulist_wp_mail_as_restaurant($to, $subject, $message, $extra_headers = array()) {
    return maca_menulist_wp_mail_with_sender(
        $to,
        $subject,
        $message,
        maca_menulist_get_menu_footer_contact_email(),
        $extra_headers
    );
}

/**
 * Send table reservation email (guest + restaurant copies) via wp_mail / site SMTP.
 *
 * @param string|array       $to            Recipient(s).
 * @param string             $subject       Email subject.
 * @param string             $message       Email body.
 * @param array<int, string> $extra_headers Optional extra headers.
 * @return bool
 */
function maca_menulist_wp_mail_for_table_booking($to, $subject, $message, $extra_headers = array()) {
    return maca_menulist_wp_mail_with_sender(
        $to,
        $subject,
        $message,
        maca_menulist_get_table_booking_notification_email(),
        $extra_headers
    );
}

/**
 * Email headers for booking notifications (From / Reply-To from table reservation settings).
 *
 * @return array<int, string>
 */
function maca_menulist_get_booking_email_headers() {
    return maca_menulist_build_table_booking_email_headers();
}

/**
 * Format booking datetime for display.
 *
 * @param object $booking Booking row.
 * @return string
 */
function maca_menulist_format_booking_datetime($booking) {
    if (!$booking) {
        return '';
    }

    $date = isset($booking->booking_date) ? $booking->booking_date : '';
    $time = isset($booking->booking_time) ? substr($booking->booking_time, 0, 5) : '';

    if ($date === '') {
        return $time;
    }

    $formatted_date = maca_menulist_format_wp_date($date);

    return trim($formatted_date . ' ' . $time);
}

/**
 * Plain-text booking summary for emails.
 *
 * @param object $booking Booking row.
 * @return string
 */
function maca_menulist_format_booking_details_for_email($booking) {
    if (!$booking) {
        return '';
    }

    $table_label = !empty($booking->table_number)
        ? sprintf(/* translators: %s: table number */ __('Table %s', 'maca-restu-pro'), $booking->table_number)
        : __('To be assigned', 'maca-restu-pro');

    $lines = array(
        sprintf(/* translators: %d: booking ID */ __('Booking reference: #%d', 'maca-restu-pro'), (int) $booking->id),
        sprintf(/* translators: %s: status label */ __('Status: %s', 'maca-restu-pro'), __('Pending confirmation', 'maca-restu-pro')),
        sprintf(/* translators: %s: guest name */ __('Name: %s', 'maca-restu-pro'), $booking->guest_name),
        sprintf(/* translators: %s: email address */ __('Email: %s', 'maca-restu-pro'), $booking->guest_email),
    );

    if (!empty($booking->guest_phone)) {
        $lines[] = sprintf(/* translators: %s: phone number */ __('Phone: %s', 'maca-restu-pro'), $booking->guest_phone);
    }

    $lines[] = sprintf(/* translators: %s: datetime */ __('Date & time: %s', 'maca-restu-pro'), maca_menulist_format_booking_datetime($booking));
    $lines[] = sprintf(/* translators: %d: party size */ __('Guests: %d', 'maca-restu-pro'), (int) $booking->party_size);
    $lines[] = sprintf(/* translators: %s: table */ __('Table: %s', 'maca-restu-pro'), $table_label);

    if (!empty($booking->guest_note)) {
        $lines[] = sprintf(/* translators: %s: guest note */ __('Note: %s', 'maca-restu-pro'), $booking->guest_note);
    }

    return implode("\n", $lines);
}

/**
 * Validate waitlist time is within opening hours (waitlist-eligible dates).
 *
 * @param string $date Date Y-m-d.
 * @param string $time Time HH:MM or HH:MM:SS.
 * @return string|WP_Error Normalized time HH:MM:SS.
 */
function maca_menulist_validate_waitlist_time_slot($date, $time) {
    $date = sanitize_text_field($date);
    $time_norm = maca_menulist_normalize_time($time);

    if ($date === '' || $time_norm === null) {
        return new WP_Error('invalid_datetime', __('Ogiltigt datum eller tid.', 'maca-restu-pro'));
    }

    if (!maca_menulist_is_date_waitlist_eligible($date)) {
        return new WP_Error('date_unavailable', __('Väntlista är inte tillgänglig detta datum.', 'maca-restu-pro'));
    }

    $hours = maca_menulist_get_waitlist_hours_for_date($date);
    if (!maca_menulist_hours_are_bookable($hours)) {
        return new WP_Error('no_hours', __('Öppettider saknas för detta datum.', 'maca-restu-pro'));
    }

    if (!maca_menulist_is_booking_start_in_hours_windows($date, $time_norm, maca_menulist_get_hours_windows($hours))) {
        return new WP_Error('outside_hours', __('Tiden ligger utanför öppettiderna.', 'maca-restu-pro'));
    }

    $now = maca_menulist_booking_now();
    $slot_dt = maca_menulist_booking_make_datetime($date, $time_norm);
    if ($date === $now->format('Y-m-d') && $slot_dt && $slot_dt <= $now) {
        return new WP_Error('past_time', __('Tiden har redan passerat.', 'maca-restu-pro'));
    }

    $slots = maca_menulist_get_waitlist_time_slots($date);
    $slot_hm = substr($time_norm, 0, 5);
    if (!in_array($slot_hm, $slots, true)) {
        return new WP_Error('invalid_slot', __('Tiden är inte tillgänglig.', 'maca-restu-pro'));
    }

    return $time_norm;
}

/**
 * Validate waitlist submission (guest or staff; no table).
 *
 * @param array<string, mixed> $data Submission data.
 * @param bool                 $require_email Whether email is required.
 * @return true|WP_Error
 */
function maca_menulist_validate_waitlist_submission($data, $require_email = true) {
    $date = isset($data['booking_date']) ? sanitize_text_field($data['booking_date']) : '';
    $time = isset($data['booking_time']) ? sanitize_text_field($data['booking_time']) : '';
    $party_size = isset($data['party_size']) ? max(0, (int) $data['party_size']) : 0;
    $guest_name = isset($data['guest_name']) ? sanitize_text_field($data['guest_name']) : '';
    $guest_email = isset($data['guest_email']) ? sanitize_email($data['guest_email']) : '';
    $guest_phone = isset($data['guest_phone']) ? sanitize_text_field($data['guest_phone']) : '';

    if (
        $date === ''
        || $time === ''
        || maca_menulist_normalize_time($time) === null
        || $party_size < 1
        || $guest_name === ''
        || $guest_phone === ''
    ) {
        return new WP_Error('required_fields', __('Please fill in all required fields.', 'maca-restu-pro'));
    }

    if ($require_email && ($guest_email === '' || !is_email($guest_email))) {
        return new WP_Error('required_email', __('E-post krävs för väntlista.', 'maca-restu-pro'));
    }

    if ($guest_email !== '' && !is_email($guest_email)) {
        return new WP_Error('invalid_email', __('Ogiltig e-postadress.', 'maca-restu-pro'));
    }

    if (!maca_menulist_table_booking_waitlist_enabled()) {
        return new WP_Error('waitlist_disabled', __('Väntlista är inte aktiverad.', 'maca-restu-pro'));
    }

    $time_check = maca_menulist_validate_waitlist_time_slot($date, $time);
    if (is_wp_error($time_check)) {
        return $time_check;
    }

    return true;
}

/**
 * Create a waitlist booking (no table; optional email).
 *
 * @param array<string, mixed> $data   Booking data.
 * @param string               $source guest|staff.
 * @return int|WP_Error Booking ID.
 */
function maca_menulist_create_waitlist_booking($data, $source = 'guest') {
    $require_email = ($source === 'guest');
    $validation = maca_menulist_validate_waitlist_submission($data, $require_email);
    if (is_wp_error($validation)) {
        return $validation;
    }

    $date = sanitize_text_field($data['booking_date']);
    $time = sanitize_text_field($data['booking_time']);
    $party_size = max(1, (int) $data['party_size']);
    $guest_name = sanitize_text_field($data['guest_name']);
    $guest_phone = sanitize_text_field($data['guest_phone']);
    $guest_email = isset($data['guest_email']) ? sanitize_email($data['guest_email']) : '';
    $guest_note = isset($data['guest_note']) ? sanitize_textarea_field($data['guest_note']) : '';
    $send_email = !empty($data['send_email']);

    if ($guest_email === '' || !is_email($guest_email)) {
        $guest_email = maca_menulist_staff_booking_placeholder_email();
    }

    $result = maca_menulist_db_insert_booking(
        array(
            'booking_date' => $date,
            'booking_time' => $time,
            'party_size'    => $party_size,
            'guest_name'    => $guest_name,
            'guest_email'   => $guest_email,
            'guest_phone'   => $guest_phone,
            'guest_note'    => $guest_note,
            'status'        => 'waitlist',
        )
    );

    if (!$result) {
        return new WP_Error('save_failed', __('Kunde inte spara bokningen.', 'maca-restu-pro'));
    }

    $booking_id = (int) $result['id'];

    if ($send_email && is_email($guest_email) && $guest_email !== maca_menulist_staff_booking_placeholder_email()) {
        maca_menulist_send_booking_email($booking_id, 'waitlist_received');
    } elseif ($source === 'guest') {
        maca_menulist_send_booking_email($booking_id, 'waitlist_received');
    }

    if (function_exists('maca_menulist_booking_log_created')) {
        maca_menulist_booking_log_created($booking_id, $source === 'staff' ? 'staff' : 'guest');
    }

    do_action('maca_menulist_booking_created', $booking_id);

    return $booking_id;
}

/**
 * Validate guest booking submission (all fields except note are required).
 *
 * @param array<string, mixed> $data Submission data.
 * @return true|WP_Error
 */
function maca_menulist_validate_table_booking_submission($data) {
    $date = isset($data['booking_date']) ? sanitize_text_field($data['booking_date']) : '';
    $time = isset($data['booking_time']) ? sanitize_text_field($data['booking_time']) : '';
    $party_size = isset($data['party_size']) ? max(0, (int) $data['party_size']) : 0;
    $table_id = isset($data['table_id']) ? absint($data['table_id']) : 0;
    $guest_name = isset($data['guest_name']) ? sanitize_text_field($data['guest_name']) : '';
    $guest_email = isset($data['guest_email']) ? sanitize_email($data['guest_email']) : '';
    $guest_phone = isset($data['guest_phone']) ? sanitize_text_field($data['guest_phone']) : '';
    $require_table = !maca_menulist_table_booking_uses_best_fit_assignment();

    if (
        $date === ''
        || $time === ''
        || maca_menulist_normalize_time($time) === null
        || $party_size < 1
        || ($require_table && $table_id <= 0)
        || $guest_name === ''
        || $guest_email === ''
        || !is_email($guest_email)
        || $guest_phone === ''
    ) {
        return new WP_Error('required_fields', __('Please fill in all required fields.', 'maca-restu-pro'));
    }

    if (!maca_menulist_is_date_bookable($date)) {
        return new WP_Error('date_unavailable', __('Bookings are not available on this date.', 'maca-restu-pro'));
    }

    return true;
}

/**
 * Whether guests can cancel bookings via email link.
 *
 * @return bool
 */
function maca_menulist_table_booking_guest_cancel_enabled() {
    if (!maca_menulist_table_booking_enabled()) {
        return false;
    }

    return get_option('maca_menulist_table_booking_guest_cancel', '1') === '1';
}

/**
 * URL slug for the guest cancellation page.
 *
 * @return string
 */
function maca_menulist_get_table_booking_cancel_slug() {
    $slug = get_option('maca_menulist_table_booking_cancel_slug', 'bokning-avbryt');
    $slug = sanitize_title($slug);

    return $slug !== '' ? $slug : 'bokning-avbryt';
}

/**
 * Guest cancellation URL for a booking token.
 *
 * @param string $guest_token Guest token.
 * @return string
 */
function maca_menulist_get_booking_cancel_url($guest_token) {
    $guest_token = sanitize_text_field($guest_token);

    if ($guest_token === '' || !maca_menulist_table_booking_guest_cancel_enabled()) {
        return '';
    }

    return add_query_arg(
        'token',
        rawurlencode($guest_token),
        home_url('/' . maca_menulist_get_table_booking_cancel_slug() . '/')
    );
}

/**
 * Whether a guest may cancel this booking.
 *
 * @param object|null $booking Booking row.
 * @return bool
 */
function maca_menulist_guest_can_cancel_booking($booking) {
    if (!$booking || !maca_menulist_table_booking_guest_cancel_enabled()) {
        return false;
    }

    if (!in_array($booking->status, array('pending', 'confirmed', 'waitlist'), true)) {
        return false;
    }

    $datetime = maca_menulist_booking_make_datetime($booking->booking_date, $booking->booking_time);

    if (!$datetime) {
        return false;
    }

    return $datetime > maca_menulist_booking_now();
}

/**
 * Cancel footer line for booking emails.
 *
 * @param object $booking Booking row.
 * @return string
 */
function maca_menulist_booking_email_cancel_footer($booking) {
    if (!$booking || !maca_menulist_guest_can_cancel_booking($booking)) {
        return '';
    }

    $cancel_url = maca_menulist_get_booking_cancel_url($booking->guest_token);

    if ($cancel_url === '') {
        return '';
    }

    return "\n\n" . sprintf(
        /* translators: %s: cancellation URL */
        __('To cancel your booking, visit:%1$s%2$s', 'maca-restu-pro'),
        "\n",
        $cancel_url
    );
}

/**
 * Cancel a booking initiated by the guest.
 *
 * @param string $guest_token Guest token.
 * @return int|WP_Error Booking ID on success.
 */
function maca_menulist_cancel_booking_by_guest($guest_token) {
    $booking = maca_menulist_db_get_booking_by_token($guest_token);

    if (!$booking || !maca_menulist_guest_can_cancel_booking($booking)) {
        return new WP_Error('cannot_cancel', __('This booking cannot be cancelled.', 'maca-restu-pro'));
    }

    $before_log = array(
        'status' => (string) $booking->status,
    );
    $after_log = array(
        'status' => 'cancelled',
    );

    if ((int) $booking->table_id > 0) {
        $before_log['table_id'] = (int) $booking->table_id;
        $after_log['table_id'] = 0;
    }

    $split_slot = isset($booking->split_slot) && function_exists('maca_menulist_normalize_split_slot')
        ? maca_menulist_normalize_split_slot((string) $booking->split_slot)
        : '';

    if ($split_slot !== '') {
        $before_log['split_slot'] = $split_slot;
        $after_log['split_slot'] = '';
    }

    if (!maca_menulist_db_update_booking_status((int) $booking->id, 'cancelled')) {
        return new WP_Error('cancel_failed', __('Could not cancel the booking.', 'maca-restu-pro'));
    }

    if (function_exists('maca_menulist_booking_log_add')) {
        maca_menulist_booking_log_add(
            (int) $booking->id,
            'cancelled',
            array(
                'summary' => __('Avbokad av gäst via länk.', 'maca-restu-pro'),
                'changes' => maca_menulist_booking_log_diff_fields($before_log, $after_log),
                'actor' => array(
                    'type' => 'guest',
                    'id' => 0,
                    'label' => __('Gäst', 'maca-restu-pro'),
                ),
            )
        );
    }

    maca_menulist_send_booking_email((int) $booking->id, 'guest_cancelled');
    maca_menulist_send_booking_email((int) $booking->id, 'guest_cancelled_admin');

    return (int) $booking->id;
}

/**
 * Send booking notification emails.
 *
 * @param int    $booking_id Booking ID.
 * @param string $type       received|confirmed|cancelled|guest_cancelled|guest_cancelled_admin.
 * @return void
 */
function maca_menulist_send_booking_email($booking_id, $type) {
    $booking = maca_menulist_db_get_booking($booking_id);

    if (!$booking) {
        return;
    }

    $restaurant = maca_menulist_get_booking_restaurant_name();
    $datetime = maca_menulist_format_booking_datetime($booking);
    $table_label = !empty($booking->table_number)
        ? sprintf(/* translators: %s: table number */ __('Table %s', 'maca-restu-pro'), $booking->table_number)
        : __('To be assigned', 'maca-restu-pro');
    $cancel_footer = maca_menulist_booking_email_cancel_footer($booking);

    $guest_subject = '';
    $guest_body = '';
    $admin_subject = '';
    $admin_body = '';
    $send_guest = false;
    $send_admin = false;

    switch ($type) {
        case 'received':
            $send_guest = true;
            $send_admin = true;
            $booking_details = maca_menulist_format_booking_details_for_email($booking);
            $guest_subject = sprintf(
                /* translators: %s: restaurant name */
                __('Your booking request – %s (awaiting confirmation)', 'maca-restu-pro'),
                $restaurant
            );
            $guest_body = sprintf(
                /* translators: 1: guest name, 2: restaurant, 3: booking details block, 4: cancel link block */
                __("Hello %1\$s,\n\nThank you for your table reservation request at %2\$s.\n\nWe have received your request with the following details:\n\n%3\$s\n\nIMPORTANT: Your booking is NOT confirmed yet. It is only valid once the restaurant has approved it. You will receive a separate confirmation email when your booking is approved.%4\$s\n\nBest regards,\n%2\$s", 'maca-restu-pro'),
                $booking->guest_name,
                $restaurant,
                $booking_details,
                $cancel_footer
            );
            $admin_subject = sprintf(
                /* translators: %s: guest name */
                __('New table reservation request from %s', 'maca-restu-pro'),
                $booking->guest_name
            );
            $admin_body = sprintf(
                /* translators: 1: name, 2: email, 3: phone, 4: datetime, 5: party size, 6: table, 7: note, 8: admin URL */
                __("New booking request (pending confirmation):\n\nName: %1\$s\nEmail: %2\$s\nPhone: %3\$s\nDate & time: %4\$s\nGuests: %5\$d\nTable: %6\$s\nNote: %7\$s\n\nManage bookings: %8\$s", 'maca-restu-pro'),
                $booking->guest_name,
                $booking->guest_email,
                $booking->guest_phone ?: '-',
                $datetime,
                (int) $booking->party_size,
                $table_label,
                $booking->guest_note ?: '-',
                admin_url('admin.php?page=maca-restu-bookings')
            );
            break;

        case 'confirmed':
            $send_guest = true;
            $guest_subject = sprintf(
                /* translators: %s: restaurant name */
                __('Booking confirmed – %s', 'maca-restu-pro'),
                $restaurant
            );
            $guest_body = sprintf(
                /* translators: 1: guest name, 2: restaurant, 3: datetime, 4: party size, 5: table, 6: cancel link block */
                __("Hello %1\$s,\n\nYour table reservation at %2\$s is now confirmed.\n\nDate & time: %3\$s\nGuests: %4\$d\nTable: %5\$s\n\nWe look forward to seeing you!%6\$s\n\nBest regards,\n%2\$s", 'maca-restu-pro'),
                $booking->guest_name,
                $restaurant,
                $datetime,
                (int) $booking->party_size,
                $table_label,
                $cancel_footer
            );
            break;

        case 'cancelled':
            $send_guest = true;
            $guest_subject = sprintf(
                /* translators: %s: restaurant name */
                __('Booking cancelled – %s', 'maca-restu-pro'),
                $restaurant
            );
            $guest_body = sprintf(
                /* translators: 1: guest name, 2: restaurant, 3: datetime */
                __("Hello %1\$s,\n\nYour table reservation at %2\$s on %3\$s has been cancelled by the restaurant.\n\nIf you have questions, please contact the restaurant.\n\nBest regards,\n%2\$s", 'maca-restu-pro'),
                $booking->guest_name,
                $restaurant,
                $datetime
            );
            break;

        case 'guest_cancelled':
            $send_guest = true;
            $guest_subject = sprintf(
                /* translators: %s: restaurant name */
                __('Booking cancelled – %s', 'maca-restu-pro'),
                $restaurant
            );
            $guest_body = sprintf(
                /* translators: 1: guest name, 2: restaurant, 3: datetime */
                __("Hello %1\$s,\n\nYour table reservation at %2\$s on %3\$s has been cancelled as requested.\n\nBest regards,\n%2\$s", 'maca-restu-pro'),
                $booking->guest_name,
                $restaurant,
                $datetime
            );
            break;

        case 'guest_cancelled_admin':
            $send_admin = true;
            $admin_subject = sprintf(
                /* translators: %s: guest name */
                __('Guest cancelled booking – %s', 'maca-restu-pro'),
                $booking->guest_name
            );
            $admin_body = sprintf(
                /* translators: 1: name, 2: email, 3: phone, 4: datetime, 5: party size, 6: table, 7: admin URL */
                __("A guest cancelled their booking:\n\nName: %1\$s\nEmail: %2\$s\nPhone: %3\$s\nDate & time: %4\$s\nGuests: %5\$d\nTable: %6\$s\n\nManage bookings: %7\$s", 'maca-restu-pro'),
                $booking->guest_name,
                $booking->guest_email,
                $booking->guest_phone ?: '-',
                $datetime,
                (int) $booking->party_size,
                $table_label,
                admin_url('admin.php?page=maca-restu-bookings')
            );
            break;

        case 'waitlist_received':
            $send_guest = true;
            $send_admin = true;
            $booking_details = maca_menulist_format_booking_details_for_email($booking);
            $guest_subject = sprintf(
                /* translators: %s: restaurant name */
                __('Waitlist registration – %s', 'maca-restu-pro'),
                $restaurant
            );
            $guest_body = sprintf(
                /* translators: 1: guest name, 2: restaurant, 3: booking details block, 4: cancel link block */
                __("Hello %1\$s,\n\nYou are on the waitlist at %2\$s for:\n\n%3\$s\n\nWe will contact you by email if a table becomes available. This is not a confirmed booking.%4\$s\n\nBest regards,\n%2\$s", 'maca-restu-pro'),
                $booking->guest_name,
                $restaurant,
                $booking_details,
                $cancel_footer
            );
            $admin_subject = sprintf(
                /* translators: %s: guest name */
                __('New waitlist entry from %s', 'maca-restu-pro'),
                $booking->guest_name
            );
            $admin_body = sprintf(
                /* translators: 1: name, 2: email, 3: phone, 4: datetime, 5: party size, 6: note, 7: admin URL */
                __("New waitlist entry:\n\nName: %1\$s\nEmail: %2\$s\nPhone: %3\$s\nDate & time: %4\$s\nGuests: %5\$d\nNote: %6\$s\n\nManage bookings: %7\$s", 'maca-restu-pro'),
                $booking->guest_name,
                $booking->guest_email,
                $booking->guest_phone ?: '-',
                $datetime,
                (int) $booking->party_size,
                $booking->guest_note ?: '-',
                admin_url('admin.php?page=maca-restu-bookings')
            );
            break;

        default:
            return;
    }

    if ($send_guest && !empty($booking->guest_email) && is_email($booking->guest_email)) {
        $guest_body = maca_menulist_booking_email_apply_arrival_notice($guest_body, $type);
        maca_menulist_wp_mail_for_table_booking($booking->guest_email, $guest_subject, $guest_body);
    }

    if ($send_admin) {
        $admin_email = maca_menulist_get_table_booking_notification_email();
        if ($admin_email && is_email($admin_email)) {
            maca_menulist_wp_mail_for_table_booking($admin_email, $admin_subject, $admin_body);
        }
    }

    /**
     * Fires after a booking email is sent.
     *
     * @param int    $booking_id Booking ID.
     * @param string $type       Email type.
     */
    do_action('maca_menulist_booking_email_sent', $booking_id, $type);
}

/**
 * Minutes the table stays reserved after the booked time (guest-facing).
 *
 * @return int 0–120; 0 hides the notice.
 */
function maca_menulist_get_table_booking_hold_minutes() {
    $minutes = absint(get_option('maca_menulist_table_booking_hold_minutes', 15));

    return min(120, $minutes);
}

/**
 * Built-in Swedish/English arrival notice strings.
 *
 * @param int $minutes Hold minutes.
 * @return array{local: string, en: string}
 */
function maca_menulist_get_table_booking_arrival_notice_builtin_strings($minutes) {
    $minutes = max(1, (int) $minutes);

    $local = sprintf(
        (1 === $minutes)
            ? 'Bordet kommer att reserveras upp till %d minut efter den tid och bokningsdag du valt. Vi uppskattar att du kommer i tid.'
            : 'Bordet kommer att reserveras upp till %d minuter efter den tid och bokningsdag du valt. Vi uppskattar att du kommer i tid.',
        $minutes
    );

    $english = sprintf(
        /* translators: %d: number of hold minutes after the booked time */
        _n(
            'The table will be reserved for up to %d minute after the date and time you selected. We appreciate you arriving on time.',
            'The table will be reserved for up to %d minutes after the date and time you selected. We appreciate you arriving on time.',
            $minutes,
            'maca-restu-pro'
        ),
        $minutes
    );

    return array(
        'local' => $local,
        'en'    => $english,
    );
}

/**
 * Resolve optional custom arrival notice (use %d for minutes).
 *
 * @param string $template Stored template.
 * @param string $fallback Fallback when empty.
 * @param int    $minutes  Hold minutes.
 * @return string
 */
function maca_menulist_resolve_table_booking_arrival_notice_text($template, $fallback, $minutes) {
    $template = trim((string) $template);

    if ($template === '') {
        return $fallback;
    }

    if (strpos($template, '%d') !== false) {
        return sprintf($template, $minutes);
    }

    return $template;
}

/**
 * Guest notice about how long the table is held after the booked time.
 *
 * @return string Empty when hold minutes is 0.
 */
function maca_menulist_get_table_booking_arrival_notice() {
    $minutes = maca_menulist_get_table_booking_hold_minutes();

    if ($minutes <= 0) {
        return '';
    }

    $builtin = maca_menulist_get_table_booking_arrival_notice_builtin_strings($minutes);
    $local = maca_menulist_resolve_table_booking_arrival_notice_text(
        get_option('maca_menulist_table_booking_arrival_notice', ''),
        $builtin['local'],
        $minutes
    );
    $english = maca_menulist_resolve_table_booking_arrival_notice_text(
        get_option('maca_menulist_table_booking_arrival_notice_en', ''),
        $builtin['en'],
        $minutes
    );

    return maca_menulist_get_bilingual_field($local, $english);
}

/**
 * Append arrival notice to the guest booking confirmation email only.
 *
 * @param string $body Email body.
 * @param string $type Email type.
 * @return string
 */
function maca_menulist_booking_email_apply_arrival_notice($body, $type) {
    if ($type !== 'confirmed') {
        return $body;
    }

    $notice = maca_menulist_get_table_booking_arrival_notice();

    if ($notice === '') {
        return $body;
    }

    $marker = "\n\n" . __('Best regards,', 'maca-restu-pro');
    $pos = strpos($body, $marker);

    if ($pos !== false) {
        return substr($body, 0, $pos) . "\n\n" . $notice . substr($body, $pos);
    }

    return $body . "\n\n" . $notice;
}

/**
 * Default booking button label (translated fallback).
 *
 * @return string
 */
function maca_menulist_get_table_booking_button_default_label() {
    return __('Book a table', 'maca-restu-pro');
}

/**
 * Configured booking button label for the current language context.
 *
 * @return string
 */
function maca_menulist_get_table_booking_button_label() {
    $local = get_option('maca_menulist_table_booking_button_text', '');
    $english = get_option('maca_menulist_table_booking_button_text_en', '');

    if (!is_string($local)) {
        $local = '';
    }
    if (!is_string($english)) {
        $english = '';
    }

    $local = trim($local);
    $english = trim($english);

    if ($local === '' && $english === '') {
        return maca_menulist_get_table_booking_button_default_label();
    }

    return maca_menulist_get_bilingual_field($local, $english);
}

/**
 * Resolve booking button label from shortcode/block override or settings.
 *
 * @param string $override Optional button attribute from shortcode or block.
 * @return string
 */
function maca_menulist_resolve_table_booking_button_label($override = '') {
    $override = trim((string) $override);

    if ($override !== '') {
        return $override;
    }

    return maca_menulist_get_table_booking_button_label();
}

/**
 * Default booking modal title (translated fallback).
 *
 * @return string
 */
function maca_menulist_get_table_booking_title_default_label() {
    return __('Table Reservation', 'maca-restu-pro');
}

/**
 * Treat legacy default titles as unset (renamed from "Table booking").
 *
 * @param string $text Stored title text.
 * @return string
 */
function maca_menulist_normalize_table_booking_title_text($text) {
    $text = trim((string) $text);

    if ($text === '') {
        return '';
    }

    $legacy = array('Table booking', 'Table Booking', 'table booking');

    if (in_array($text, $legacy, true)) {
        return '';
    }

    return $text;
}

/**
 * Whether a custom booking button label is configured in settings.
 *
 * @return bool
 */
function maca_menulist_has_custom_table_booking_button_label() {
    $local = get_option('maca_menulist_table_booking_button_text', '');
    $english = get_option('maca_menulist_table_booking_button_text_en', '');

    if (!is_string($local)) {
        $local = '';
    }
    if (!is_string($english)) {
        $english = '';
    }

    return trim($local) !== '' || trim($english) !== '';
}

/**
 * Configured booking modal title for the current language context.
 *
 * @return string
 */
function maca_menulist_get_table_booking_modal_title() {
    $local = get_option('maca_menulist_table_booking_title_text', '');
    $english = get_option('maca_menulist_table_booking_title_text_en', '');

    if (!is_string($local)) {
        $local = '';
    }
    if (!is_string($english)) {
        $english = '';
    }

    $local = maca_menulist_normalize_table_booking_title_text($local);
    $english = maca_menulist_normalize_table_booking_title_text($english);

    if ($local === '' && $english === '') {
        if (maca_menulist_has_custom_table_booking_button_label()) {
            return maca_menulist_get_table_booking_button_label();
        }

        return maca_menulist_get_table_booking_title_default_label();
    }

    return maca_menulist_get_bilingual_field($local, $english);
}

/**
 * Resolve booking modal title from shortcode/block override or settings.
 *
 * @param string $override Optional title attribute from shortcode or block.
 * @return string
 */
function maca_menulist_resolve_table_booking_modal_title($override = '') {
    $override = trim((string) $override);

    if ($override !== '') {
        return $override;
    }

    return maca_menulist_get_table_booking_modal_title();
}

/**
 * Script config for frontend booking form.
 *
 * @return array<string, mixed>
 */
function maca_menulist_get_table_booking_script_config() {
    return array(
        'ajaxUrl' => admin_url('admin-ajax.php'),
        'nonce' => wp_create_nonce('maca_table_booking'),
        'slotDuration' => maca_menulist_table_booking_slot_duration(),
        'advanceDays' => maca_menulist_table_booking_advance_days(),
        'assignmentMode' => maca_menulist_get_table_booking_assignment_mode(),
        'waitlistEnabled' => maca_menulist_table_booking_waitlist_enabled(),
        'minDate' => wp_date('Y-m-d'),
        'maxDate' => maca_menulist_wp_now_modify('+' . maca_menulist_table_booking_advance_days() . ' days'),
        'captcha' => array(
            'enabled' => maca_menulist_table_booking_captcha_enabled(),
            'provider' => maca_menulist_get_table_booking_captcha_provider(),
            'siteKey' => maca_menulist_get_table_booking_captcha_site_key(),
        ),
        'strings' => array(
            'loading' => __('Loading…', 'maca-restu-pro'),
            'noSlots' => __('No available times on this date.', 'maca-restu-pro'),
            'noTables' => __('No tables available for this time.', 'maca-restu-pro'),
            'noTablesConfigured' => __('No tables are configured yet. Contact the restaurant.', 'maca-restu-pro'),
            'selectDate' => __('Select a date', 'maca-restu-pro'),
            'selectTime' => __('Select a time', 'maca-restu-pro'),
            'selectTable' => __('Select a table', 'maca-restu-pro'),
            'seats' => __('seats', 'maca-restu-pro'),
            'table' => __('Table', 'maca-restu-pro'),
            'submit' => __('Send booking request', 'maca-restu-pro'),
            'success' => __('Thank you! Your booking request has been sent. The booking is not valid until the restaurant confirms it — you will receive a confirmation email when it is approved.', 'maca-restu-pro'),
            'error' => __('Could not complete the booking. Please try again.', 'maca-restu-pro'),
            'captchaRequired' => __('Please complete the security check.', 'maca-restu-pro'),
            'requiredFields' => __('Please fill in all required fields.', 'maca-restu-pro'),
            'closed' => __('Bookings are not available on this date.', 'maca-restu-pro'),
            'waitlistSubmit' => __('Join waitlist', 'maca-restu-pro'),
            'waitlistSuccess' => __('Thank you! You are on the waitlist. We will email you if a table becomes available.', 'maca-restu-pro'),
            'waitlistHint' => __('Fully booked at this time? Join the waitlist with your preferred date and time.', 'maca-restu-pro'),
        ),
    );
}

/**
 * Render the guest booking form.
 *
 * @param array<string, mixed> $atts Shortcode attributes.
 * @return string
 */
function maca_menulist_render_table_booking_form($atts = array(), $args = array()) {
    $args = wp_parse_args(
        $args,
        array(
            'preview' => maca_menulist_is_rest_block_render(),
        )
    );

    if (!maca_menulist_pro_has_feature('table_bookings')) {
        if (!empty($args['preview'])) {
            return '<p class="maca-table-booking-notice">' . esc_html__('Table Reservation requires an active Pro subscription.', 'maca-restu-pro') . '</p>';
        }
        return '';
    }

    $is_preview = !empty($args['preview']);
    $is_live = maca_menulist_table_booking_enabled();

    if (!$is_live && !$is_preview) {
        return '';
    }

    $atts = shortcode_atts(
        array(
            'button' => '',
            'title' => '',
        ),
        is_array($atts) ? $atts : array(),
        'maca_table_booking'
    );

    $atts['button'] = maca_menulist_resolve_table_booking_button_label($atts['button']);
    $atts['title'] = maca_menulist_resolve_table_booking_modal_title($atts['title']);

    maca_menulist_register_table_booking_assets();
    wp_enqueue_style('maca-menulist-style');

    if ($is_live) {
        $GLOBALS['maca_menulist_booking_widget_rendered'] = true;
        maca_menulist_enqueue_table_booking_captcha_assets();
        wp_enqueue_script('maca-menulist-table-booking');
    }

    $best_fit_assignment = maca_menulist_table_booking_uses_best_fit_assignment();

    $uid = wp_unique_id('maca-booking-');

    ob_start();
    ?>
    <div class="maca-table-booking" data-maca-booking="1">
        <?php if ($is_preview && !$is_live) : ?>
            <p class="maca-table-booking-notice">
                <?php
                echo wp_kses_post(
                    sprintf(
                        /* translators: %s: link to settings page */
                        __('Preview only — enable table reservation under %s.', 'maca-restu-pro'),
                        '<a href="' . esc_url(admin_url('admin.php?page=maca-restu-settings')) . '">' . esc_html__('maca Restu → Settings', 'maca-restu-pro') . '</a>'
                    )
                );
                ?>
            </p>
        <?php endif; ?>
        <button type="button" class="maca-category-button maca-table-booking-trigger">
            <span class="maca-category-name"><?php echo esc_html($atts['button']); ?></span>
        </button>

        <div class="maca-table-booking-modal" hidden>
            <div class="maca-table-booking-backdrop" tabindex="-1"></div>
            <div class="maca-table-booking-panel" role="dialog" aria-modal="true" aria-labelledby="<?php echo esc_attr($uid); ?>-title">
                <button type="button" class="maca-table-booking-close" aria-label="<?php esc_attr_e('Close', 'maca-restu-pro'); ?>">&times;</button>
                <h2 id="<?php echo esc_attr($uid); ?>-title" class="maca-table-booking-title"><?php echo esc_html($atts['title']); ?></h2>

                <form class="maca-table-booking-form">
                    <div class="maca-booking-field">
                        <label for="<?php echo esc_attr($uid); ?>-date"><?php esc_html_e('Date', 'maca-restu-pro'); ?></label>
                        <input type="date" id="<?php echo esc_attr($uid); ?>-date" name="booking_date" required
                            min="<?php echo esc_attr(wp_date('Y-m-d')); ?>"
                            max="<?php echo esc_attr(maca_menulist_wp_now_modify('+' . maca_menulist_table_booking_advance_days() . ' days')); ?>">
                    </div>

                    <div class="maca-booking-field">
                        <label for="<?php echo esc_attr($uid); ?>-party"><?php esc_html_e('Number of guests', 'maca-restu-pro'); ?></label>
                        <input type="number" id="<?php echo esc_attr($uid); ?>-party" name="party_size" min="1" max="20" value="2" required>
                    </div>

                    <div class="maca-booking-field maca-booking-times-wrap" hidden>
                        <label for="<?php echo esc_attr($uid); ?>-time"><?php esc_html_e('Time', 'maca-restu-pro'); ?></label>
                        <select id="<?php echo esc_attr($uid); ?>-time" name="booking_time" required disabled>
                            <option value=""><?php esc_html_e('Select a date first', 'maca-restu-pro'); ?></option>
                        </select>
                        <?php if ($best_fit_assignment) : ?>
                            <p class="description maca-booking-auto-table-note"><?php esc_html_e('The smallest available table for your party size is reserved automatically.', 'maca-restu-pro'); ?></p>
                        <?php endif; ?>
                    </div>

                    <?php if (!$best_fit_assignment) : ?>
                    <div class="maca-booking-field maca-booking-tables-wrap" hidden>
                        <label><?php esc_html_e('Available tables', 'maca-restu-pro'); ?> <span class="required" aria-hidden="true">*</span></label>
                        <div class="maca-booking-tables" role="radiogroup" aria-required="true"></div>
                    </div>
                    <?php endif; ?>

                    <div class="maca-booking-field">
                        <label for="<?php echo esc_attr($uid); ?>-name"><?php esc_html_e('Name', 'maca-restu-pro'); ?></label>
                        <input type="text" id="<?php echo esc_attr($uid); ?>-name" name="guest_name" required autocomplete="name">
                    </div>

                    <div class="maca-booking-field">
                        <label for="<?php echo esc_attr($uid); ?>-email"><?php esc_html_e('Email', 'maca-restu-pro'); ?></label>
                        <input type="email" id="<?php echo esc_attr($uid); ?>-email" name="guest_email" required autocomplete="email">
                    </div>

                    <div class="maca-booking-field">
                        <label for="<?php echo esc_attr($uid); ?>-phone"><?php esc_html_e('Phone', 'maca-restu-pro'); ?></label>
                        <input type="tel" id="<?php echo esc_attr($uid); ?>-phone" name="guest_phone" required autocomplete="tel">
                    </div>

                    <div class="maca-booking-field">
                        <label for="<?php echo esc_attr($uid); ?>-note"><?php esc_html_e('Note (optional)', 'maca-restu-pro'); ?></label>
                        <textarea id="<?php echo esc_attr($uid); ?>-note" name="guest_note" rows="3"></textarea>
                    </div>

                    <?php
                    if ($is_live) {
                        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in helper.
                        echo maca_menulist_render_table_booking_captcha_field();
                    }
                    ?>

                    <p class="maca-booking-message" hidden></p>

                    <button type="submit" class="maca-category-button maca-table-booking-submit" disabled>
                        <span class="maca-category-name"><?php esc_html_e('Send booking request', 'maca-restu-pro'); ?></span>
                    </button>
                    <?php if (maca_menulist_table_booking_waitlist_enabled()) : ?>
                    <p class="maca-booking-waitlist-wrap" hidden>
                        <span class="description maca-booking-waitlist-hint"><?php esc_html_e('Fully booked at this time? Join the waitlist with your preferred date and time.', 'maca-restu-pro'); ?></span>
                        <button type="button" class="maca-category-button maca-table-booking-waitlist-submit" disabled>
                            <span class="maca-category-name"><?php esc_html_e('Join waitlist', 'maca-restu-pro'); ?></span>
                        </button>
                    </p>
                    <?php endif; ?>
                </form>
            </div>
        </div>
    </div>
    <?php
    return (string) ob_get_clean();
}

/**
 * Guest-facing time text for opening hours (weekly row or effective date hours).
 *
 * @param array<string, mixed> $hours        Weekly day hours or maca_menulist_get_booking_hours_for_date().
 * @param string               $exception_type Optional exception type for that date.
 * @return string
 */
function maca_menulist_format_opening_hours_schedule_text($hours, $exception_type = '') {
    $exception_type = sanitize_key($exception_type);

    if ($exception_type === 'fully_booked') {
        return __('Fully booked', 'maca-restu-pro');
    }

    $closed = !empty($hours['closed']);
    if (isset($hours['bookable'])) {
        $closed = $closed || empty($hours['bookable']);
    }

    if ($closed) {
        return __('Closed', 'maca-restu-pro');
    }

    $windows_text = maca_menulist_format_hours_windows_text(maca_menulist_get_hours_windows($hours));
    if ($windows_text !== '') {
        return $windows_text;
    }

    return __('Closed', 'maca-restu-pro');
}

/**
 * Upcoming schedule exceptions for the opening hours block.
 *
 * @param string $from Start date Y-m-d (default today).
 * @param string $to   End date Y-m-d (default last bookable day).
 * @return array<int, object>
 */
function maca_menulist_get_opening_hours_display_exceptions($from = '', $to = '') {
    if ($from === '') {
        $from = wp_date('Y-m-d');
    }

    if ($to === '') {
        $to = maca_menulist_booking_now()
            ->modify('+' . maca_menulist_table_booking_advance_days() . ' days')
            ->format('Y-m-d');
    }

    return maca_menulist_db_get_booking_exceptions($from, $to);
}

/**
 * Label + hours text for one exception in the opening hours block.
 *
 * @param object $exception Exception row.
 * @return array{date_label: string, time_text: string, note: string}
 */
function maca_menulist_format_opening_hours_exception_row($exception) {
    $date = isset($exception->exception_date) ? (string) $exception->exception_date : '';
    $type = isset($exception->exception_type) ? sanitize_key((string) $exception->exception_type) : 'closed';
    $note = isset($exception->note) ? trim((string) $exception->note) : '';

    $date_label = $date !== ''
        ? maca_menulist_format_wp_date($date)
        : '';

    if ($type === 'custom_hours') {
        $effective = maca_menulist_get_booking_hours_for_date($date);
        $time_text = maca_menulist_format_opening_hours_schedule_text($effective, $type);
    } else {
        $time_text = maca_menulist_format_opening_hours_schedule_text(
            array('closed' => true, 'bookable' => false),
            $type
        );
    }

    return array(
        'date_label' => $date_label,
        'time_text'  => $time_text,
        'note'       => $note,
    );
}

/**
 * Weekday order for opening hours display (Monday first by default).
 *
 * @param bool $monday_first Start week on Monday.
 * @return array<int, int>
 */
function maca_menulist_get_opening_hours_day_order($monday_first = true) {
    return maca_menulist_weekday_day_order($monday_first);
}

/**
 * Render weekly opening hours (from table reservation schedule).
 *
 * @param array<string, mixed> $atts    Shortcode/block attributes.
 * @param array<string, mixed> $args     Render args.
 * @return string
 */
function maca_menulist_render_opening_hours($atts = array(), $args = array()) {
    $args = wp_parse_args(
        $args,
        array(
            'preview' => maca_menulist_is_rest_block_render(),
        )
    );

    $atts = shortcode_atts(
        array(
            'title' => __('Opening hours', 'maca-restu-pro'),
            'show_title' => '1',
            'highlight_today' => '1',
            'monday_first' => '1',
            'show_exceptions' => '1',
        ),
        is_array($atts) ? $atts : array(),
        'maca_opening_hours'
    );

    $show_title = $atts['show_title'] === '1' || $atts['show_title'] === 'true' || $atts['show_title'] === true;
    $highlight_today = $atts['highlight_today'] === '1' || $atts['highlight_today'] === 'true' || $atts['highlight_today'] === true;
    $monday_first = true;
    $show_exceptions = $atts['show_exceptions'] === '1' || $atts['show_exceptions'] === 'true' || $atts['show_exceptions'] === true;

    $hours = maca_menulist_get_booking_hours();
    $labels = maca_menulist_weekday_labels();
    $today_wday = (int) wp_date('w');
    $today_date = wp_date('Y-m-d');
    $day_order = maca_menulist_get_opening_hours_day_order($monday_first);
    $today_exception = maca_menulist_get_booking_exception_for_date($today_date);
    $future_exceptions = array();

    if ($show_exceptions) {
        $tomorrow = maca_menulist_wp_now_modify('+1 day');
        $future_exceptions = maca_menulist_get_opening_hours_display_exceptions($tomorrow);
    }

    wp_enqueue_style('maca-menulist-style');

    ob_start();
    ?>
    <div class="maca-opening-hours">
        <?php if ($show_title && $atts['title'] !== '') : ?>
            <h3 class="maca-opening-hours-title"><?php echo esc_html($atts['title']); ?></h3>
        <?php endif; ?>

        <ul class="maca-opening-hours-list">
            <?php foreach ($day_order as $day) : ?>
                <?php
                $day_hours = isset($hours[ $day ]) ? maca_menulist_normalize_stored_day_hours($hours[ $day ]) : maca_menulist_normalize_stored_day_hours(array('closed' => true));
                $is_today = $highlight_today && $day === $today_wday;
                $exception_type = '';
                $schedule_hours = !empty($day_hours['closed'])
                    ? array('closed' => true, 'bookable' => false)
                    : maca_menulist_build_bookable_hours_result($day_hours['windows']);

                if ($is_today) {
                    $schedule_hours = maca_menulist_get_booking_hours_for_date($today_date);
                    if ($today_exception) {
                        $exception_type = (string) $today_exception->exception_type;
                    }
                }

                $item_classes = 'maca-opening-hours-item';
                if ($is_today) {
                    $item_classes .= ' maca-opening-hours-today';
                }
                if ($is_today && $today_exception) {
                    $item_classes .= ' maca-opening-hours-exception';
                }

                $recurring_lines = array();
                foreach (maca_menulist_get_display_recurring_exceptions_for_weekday($day) as $recurring_exception) {
                    $line = maca_menulist_format_opening_hours_recurring_line($recurring_exception);
                    if ($line !== '') {
                        $recurring_lines[] = $line;
                    }
                }
                ?>
                <li class="<?php echo esc_attr($item_classes); ?>">
                    <div class="maca-opening-hours-row">
                        <span class="maca-opening-hours-day"><?php echo esc_html($labels[$day]); ?></span>
                        <?php
                        echo maca_menulist_render_opening_hours_schedule_time_html($schedule_hours, $exception_type); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
                        ?>
                    </div>
                    <?php if (!empty($recurring_lines)) : ?>
                        <ul class="maca-opening-hours-recurring">
                            <?php foreach ($recurring_lines as $recurring_line) : ?>
                                <li class="maca-opening-hours-recurring-item"><?php echo esc_html($recurring_line); ?></li>
                            <?php endforeach; ?>
                        </ul>
                    <?php endif; ?>
                </li>
            <?php endforeach; ?>
        </ul>

        <?php if ($show_exceptions && !empty($future_exceptions)) : ?>
            <div class="maca-opening-hours-exceptions">
                <h4 class="maca-opening-hours-exceptions-title"><?php esc_html_e('Special dates', 'maca-restu-pro'); ?></h4>
                <ul class="maca-opening-hours-list maca-opening-hours-exceptions-list">
                    <?php foreach ($future_exceptions as $exception) : ?>
                        <?php
                        $row = maca_menulist_format_opening_hours_exception_row($exception);
                        $type = isset($exception->exception_type) ? sanitize_key((string) $exception->exception_type) : '';
                        $exception_date = isset($exception->exception_date) ? (string) $exception->exception_date : '';

                        if ($type === 'custom_hours' && $exception_date !== '') {
                            $exception_time_html = maca_menulist_render_opening_hours_schedule_time_html(
                                maca_menulist_get_booking_hours_for_date($exception_date),
                                $type
                            );
                        } else {
                            $exception_time_html = maca_menulist_render_opening_hours_schedule_time_html(
                                array('closed' => true, 'bookable' => false),
                                $type
                            );
                        }
                        ?>
                        <li class="maca-opening-hours-item maca-opening-hours-exception maca-opening-hours-exception-<?php echo esc_attr($type); ?>">
                            <div class="maca-opening-hours-row">
                                <span class="maca-opening-hours-day"><?php echo esc_html($row['date_label']); ?></span>
                                <span class="maca-opening-hours-time-wrap">
                                    <?php
                                    echo $exception_time_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
                                    if ($row['note'] !== '') :
                                        ?>
                                        <span class="maca-opening-hours-note"><?php echo esc_html($row['note']); ?></span>
                                    <?php endif; ?>
                                </span>
                            </div>
                        </li>
                    <?php endforeach; ?>
                </ul>
            </div>
        <?php endif; ?>
    </div>
    <?php
    return (string) ob_get_clean();
}
