<?php
if ( ! defined( 'ABSPATH' ) ) {
    exit;
}
/**
 * YATOON_Business_Hours
 *
 * Core: manage business hours, generate local time slots, check conflicts
 * Pro mode uses Square API live queries; this class is used in local/free mode only
 */
class YATOON_Business_Hours {

    /**
     * Get the option key used for manual staff time-off blocks.
     */
    public static function get_staff_time_off_option_key( $staff_id ) {
        return 'yatoon_staff_time_off_' . absint( $staff_id );
    }

    /**
     * Normalize free-form textarea input for staff time-off rules.
     *
     * Supported formats:
     * - 2026-05-01 09:00|2026-05-01 13:00|Vacation
     * - 2026-05-02|all_day|Conference
     */
    public static function normalize_staff_time_off_rules( $raw_rules ) {
        $raw_rules = str_replace( array( "\r\n", "\r" ), "\n", (string) $raw_rules );
        $lines = array_filter( array_map( 'trim', explode( "\n", $raw_rules ) ) );
        $clean = array();

        foreach ( $lines as $line ) {
            $parts = array_map( 'trim', explode( '|', $line ) );
            if ( count( $parts ) < 2 ) {
                continue;
            }

            $start = sanitize_text_field( $parts[0] );
            $end   = sanitize_text_field( $parts[1] );
            $note  = isset( $parts[2] ) ? sanitize_text_field( $parts[2] ) : '';

            if ( preg_match( '/^\d{4}-\d{2}-\d{2}$/', $start ) && strtolower( $end ) === 'all_day' ) {
                $normalized = $start . '|all_day';
                if ( $note !== '' ) {
                    $normalized .= '|' . $note;
                }
                $clean[] = $normalized;
                continue;
            }

            if (
                preg_match( '/^\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}$/', $start ) &&
                preg_match( '/^\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}$/', $end )
            ) {
                try {
                    $start_dt = new DateTime( $start );
                    $end_dt   = new DateTime( $end );
                    if ( $end_dt > $start_dt ) {
                        $normalized = $start_dt->format( 'Y-m-d H:i' ) . '|' . $end_dt->format( 'Y-m-d H:i' );
                        if ( $note !== '' ) {
                            $normalized .= '|' . $note;
                        }
                        $clean[] = $normalized;
                    }
                } catch ( Exception $e ) {
                    continue;
                }
            }
        }

        return implode( "\n", array_unique( $clean ) );
    }

    /**
     * Get parsed staff time-off blocks.
     *
     * @return array<int, array{start:DateTime,end:DateTime,reason:string,is_all_day:bool}>
     */
    public static function get_staff_time_off_blocks( $staff_id, $tz = null ) {
        $staff_id = absint( $staff_id );
        if ( ! $staff_id ) {
            return array();
        }

        if ( ! ( $tz instanceof DateTimeZone ) ) {
            $tz = yatoon_timezone();
        }

        $saved = (string) get_option( self::get_staff_time_off_option_key( $staff_id ), '' );
        $saved = self::normalize_staff_time_off_rules( $saved );
        if ( $saved === '' ) {
            return array();
        }

        $blocks = array();
        $lines = array_filter( array_map( 'trim', explode( "\n", $saved ) ) );
        foreach ( $lines as $line ) {
            $parts = array_map( 'trim', explode( '|', $line ) );
            if ( count( $parts ) < 2 ) {
                continue;
            }

            $reason = isset( $parts[2] ) ? $parts[2] : '';

            try {
                if ( preg_match( '/^\d{4}-\d{2}-\d{2}$/', $parts[0] ) && strtolower( $parts[1] ) === 'all_day' ) {
                    $start = new DateTime( $parts[0] . ' 00:00:00', $tz );
                    $end   = clone $start;
                    $end->modify( '+1 day' );
                    $blocks[] = array(
                        'start'      => $start,
                        'end'        => $end,
                        'reason'     => $reason,
                        'is_all_day' => true,
                    );
                    continue;
                }

                $start = new DateTime( $parts[0], $tz );
                $end   = new DateTime( $parts[1], $tz );
                if ( $end <= $start ) {
                    continue;
                }
                $blocks[] = array(
                    'start'      => $start,
                    'end'        => $end,
                    'reason'     => $reason,
                    'is_all_day' => false,
                );
            } catch ( Exception $e ) {
                continue;
            }
        }

        return $blocks;
    }

    /**
     * Return the raw textarea value for admin editing.
     */
    public static function get_staff_time_off_rules( $staff_id ) {
        return (string) get_option( self::get_staff_time_off_option_key( $staff_id ), '' );
    }

    /**
     * True when the slot overlaps a manual staff time-off block.
     */
    public static function has_staff_time_off_conflict( $staff_id, $slot_start, $slot_end, $tz = null ) {
        $staff_id = absint( $staff_id );
        if ( ! $staff_id ) {
            return false;
        }

        if ( ! ( $slot_start instanceof DateTime ) || ! ( $slot_end instanceof DateTime ) ) {
            return false;
        }

        if ( class_exists( 'YATOON_Group_Appointments' ) && YATOON_Group_Appointments::staff_has_session_conflict( $staff_id, $slot_start, $slot_end ) ) {
            return true;
        }

        foreach ( self::get_staff_time_off_blocks( $staff_id, $tz ) as $block ) {
            if ( $slot_start < $block['end'] && $slot_end > $block['start'] ) {
                return true;
            }
        }

        return false;
    }

    /**
     * Default business hours structure
     */
    public static function get_default_hours() {
        return array(
            'mon' => array('open' => true,  'start' => '09:00', 'end' => '18:00', 'break_start' => '', 'break_end' => ''),
            'tue' => array('open' => true,  'start' => '09:00', 'end' => '18:00', 'break_start' => '', 'break_end' => ''),
            'wed' => array('open' => true,  'start' => '09:00', 'end' => '18:00', 'break_start' => '', 'break_end' => ''),
            'thu' => array('open' => true,  'start' => '09:00', 'end' => '18:00', 'break_start' => '', 'break_end' => ''),
            'fri' => array('open' => true,  'start' => '09:00', 'end' => '18:00', 'break_start' => '', 'break_end' => ''),
            'sat' => array('open' => true,  'start' => '10:00', 'end' => '17:00', 'break_start' => '', 'break_end' => ''),
            'sun' => array('open' => false, 'start' => '10:00', 'end' => '17:00', 'break_start' => '', 'break_end' => ''),
        );
    }

    /**
     * Get current business hours settings
     */
    public static function get_hours() {
        $saved = get_option('yatoon_business_hours', '');
        if ($saved && is_array($hours = json_decode($saved, true))) {
            // Merge with defaults to handle newly added fields
            $defaults = self::get_default_hours();
            foreach ($defaults as $day => $vals) {
                if (!isset($hours[ $day ])) { $hours[ $day ] = $vals;
                }
                $hours[ $day ] = array_merge($vals, $hours[ $day ]);
            }
            return $hours;
        }
        return self::get_default_hours();
    }

    /**
     * Save business hours settings
     */
    public static function save_hours( $data ) {
        $days    = array('mon','tue','wed','thu','fri','sat','sun');
        $cleaned = array();
        foreach ($days as $day) {
            $cleaned[ $day ] = array(
                'open'        => !empty($data[ $day ]['open']),
                'start'       => sanitize_text_field($data[ $day ]['start'] ?? '09:00'),
                'end'         => sanitize_text_field($data[ $day ]['end']   ?? '18:00'),
                'break_start' => sanitize_text_field($data[ $day ]['break_start'] ?? ''),
                'break_end'   => sanitize_text_field($data[ $day ]['break_end']   ?? ''),
            );
        }
        update_option('yatoon_business_hours', json_encode($cleaned));
    }

    /**
     * Get whether a day is open and its time range
     * @param string $date  Y-m-d
     * @return array|false  false = closed
     */
    public static function get_day_hours( $date ) {
        $hours    = self::get_hours();
        $local_tz = yatoon_timezone();
        $day_dt   = new DateTimeImmutable($date . ' 12:00:00', $local_tz);
        $day_key  = strtolower($day_dt->format('D')); // mon/tue/...
        $day_data = $hours[ $day_key ] ?? null;
        if (!$day_data || empty($day_data['open'])) { return false;
        }
        return $day_data;
    }

    /**
     * Generate all available time slots for a day (excluding conflicts and breaks)
     *
     * KEY FIX: Now respects service-staff assignments.
     * - If staff_id > 0 (named staff): check only that staff member's bookings.
     * - If staff_id = 0 (Any staff):   find all staff who CAN do this service,
     *   then show a slot only if AT LEAST ONE of those staff is free.
     *   This prevents: (a) showing Lisa's free slots for Acrylic when Lisa can't do Acrylic,
     *                  (b) hiding slots when only one staff is booked but another is still free.
     *
     * @param string $date        Y-m-d
     * @param int    $service_id
     * @param int    $staff_id    0 = any staff
     * @return array  [['time'=>'09:00','display'=>'9:00 AM','start_at'=>'ISO8601'],...]
     */
	    public static function generate_slots( $date, $service_id, $staff_id = 0, $duration_override = null ) {
        $day = self::get_day_hours($date);
        if (!$day) { return array(); // closed
        }

        global $wpdb;

        // Get service duration
        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $service  = $wpdb->get_row($wpdb->prepare(
            "SELECT duration_minutes FROM {$wpdb->prefix}yatoon_services WHERE id = %d", $service_id
        ));
	        $duration = $duration_override ? intval($duration_override) : ( $service ? intval($service->duration_minutes) : 60 );
	        if ($duration < 15) { $duration = 30;
            }

        $interval = intval(get_option('yatoon_slot_interval', 30));

        $timezone = yatoon_timezone_string();
        $tz       = new DateTimeZone($timezone);
        $now      = new DateTime('now', $tz);

        $segments = self::build_segments($day, $date, $tz);

        // -- Determine which staff members are eligible for this service --------
        if ($staff_id > 0) {
            // Named staff: check only them
            $eligible_staff_ids = array($staff_id);
        } else {
            // Any staff: only staff who are assigned to this service
            $table_ss  = $wpdb->prefix . 'yatoon_staff_services'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
            $table_stf = $wpdb->prefix . 'yatoon_staff'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching

            // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $assigned = $wpdb->get_col($wpdb->prepare(
                "SELECT s.id FROM $table_stf s
                 INNER JOIN $table_ss ss ON s.id = ss.staff_id
                 WHERE ss.service_id = %d AND s.is_active = 1 AND s.is_bookable = 1
                 ORDER BY s.sort_order ASC, s.name ASC",
                $service_id
            ));

            if (!empty($assigned)) {
                $eligible_staff_ids = array_map('intval', $assigned);
            } else {
                // Local mode should keep taking bookings even if the assignment table
                // is empty or an older install has not saved service assignments yet.
                $eligible_staff_ids = array_map('intval', (array) $wpdb->get_col(
                    "SELECT id FROM $table_stf WHERE is_active = 1 AND is_bookable = 1 ORDER BY sort_order ASC, name ASC"
                ));
            }
        }

        if (empty($eligible_staff_ids)) { return array();
        }

        // Pre-load bookings per eligible staff member
        $bookings_by_staff = array();
        foreach ($eligible_staff_ids as $sid) {
            $bookings_by_staff[ $sid ] = self::get_booked_slots($date, $sid);
        }

        $slots = array();

        foreach ($segments as $seg) {
            $cursor = clone $seg['start'];

            while (true) {
                $slot_end = clone $cursor;
                $slot_end->modify("+{$duration} minutes");

                if ($slot_end > $seg['end']) { break;
                }

                $is_today  = ( $cursor->format('Y-m-d') === $now->format('Y-m-d') );
                $in_future = ( $cursor > $now );

                if ( !$is_today || $in_future ) {
                    // Slot is available if AT LEAST ONE eligible staff member is free
                    $someone_free = false;
                    foreach ($eligible_staff_ids as $sid) {
                        if (
                            !self::has_conflict($cursor, $slot_end, $bookings_by_staff[ $sid ]) &&
                            !self::has_staff_time_off_conflict( $sid, $cursor, $slot_end, $tz )
                        ) {
                            $someone_free = true;
                            break;
                        }
                    }

                    if ($someone_free) {
                        $slots[] = array(
                            'time'     => $cursor->format('H:i'),
                            'display'  => $cursor->format('g:i A'),
                            'start_at' => $cursor->format('c'),
                        );
                    }
                }

                $cursor->modify("+{$interval} minutes");
            }
        }

        return $slots;
    }

    /**
     * Split business hours + breaks into continuous open segments
     */
    private static function build_segments( $day, $date, $tz ) {
        $open  = new DateTime($date . ' ' . $day['start'], $tz);
        $close = new DateTime($date . ' ' . $day['end'],   $tz);

        if (!empty($day['break_start']) && !empty($day['break_end'])) {
            $bstart = new DateTime($date . ' ' . $day['break_start'], $tz);
            $bend   = new DateTime($date . ' ' . $day['break_end'],   $tz);
            if ($bstart > $open && $bend < $close) {
                return array(
                    array('start' => $open,  'end' => $bstart),
                    array('start' => $bend,  'end' => $close),
                );
            }
        }
        return array(array('start' => $open, 'end' => $close));
    }

    /**
     * Query booked time slots for a day
     */
    private static function get_booked_slots( $date, $staff_id = null ) {
        global $wpdb;
        $table = $wpdb->prefix . 'yatoon_bookings'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching

        $sql  = "SELECT start_time, end_time FROM $table
                 WHERE DATE(start_time) = %s
                   AND status NOT IN ('cancelled','cancelled_by_customer','CANCELLED_BY_CUSTOMER','CANCELLED_BY_SELLER','DECLINED','no_show')"; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table uses $wpdb->prefix directly
        $args = array($date);

        if ($staff_id) {
            $sql   .= ' AND staff_id = %d';
            $args[] = $staff_id;
        }

        $rows = $wpdb->get_results($wpdb->prepare($sql, ...$args)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- dynamic WHERE clause, table uses $wpdb->prefixctQuery
        $rows = is_array($rows) ? $rows : array();

        // 4.4.3: fold in busy time from the staff member's own external
        // calendar.
        //
        // Every conflict check in the plugin funnels through this method --
        // has_conflict(), get_available_slots_for_date() and the multi-service
        // checker in class-yatoon-ajax.php all call it. Returning external
        // busy periods as ordinary {start_time, end_time} rows therefore
        // blocks them everywhere at once, with no changes to any caller.
        //
        // Only meaningful for a specific staff member: "is anyone free?" has
        // no external calendar to consult.
        if ($staff_id) {
            foreach (self::get_external_busy_slots($date, $staff_id) as $busy) {
                $rows[] = $busy;
            }
        }

        return $rows;
    }

    /**
     * Does [$start, $end) collide with the staff member's external calendar?
     *
     * get_booked_slots() covers everything that goes through the slot-list
     * path, but the final gate before INSERT in class-yatoon-ajax.php runs its
     * own raw overlap query for speed. Without this, an external conflict
     * would grey the slot out in the picker yet still be accepted on submit --
     * which is exactly the sort of "filtered but not enforced" gap that let
     * double bookings through before 4.4.3.
     *
     * @since 4.4.3
     * @param int      $staff_id
     * @param DateTime $start
     * @param DateTime $end
     * @return bool
     */
    public static function has_external_busy_conflict( $staff_id, $start, $end ) {
        $staff_id = (int) $staff_id;
        if ( $staff_id <= 0 ) {
            return false;
        }

        // A booking can straddle midnight, so check both local days.
        $dates = array_unique( array( $start->format( 'Y-m-d' ), $end->format( 'Y-m-d' ) ) );

        foreach ( $dates as $date ) {
            foreach ( self::get_external_busy_slots( $date, $staff_id ) as $busy ) {
                try {
                    $busy_start = new DateTime( $busy->start_time, yatoon_timezone() );
                    $busy_end   = new DateTime( $busy->end_time, yatoon_timezone() );
                } catch ( Exception $e ) {
                    continue;
                }
                if ( $start < $busy_end && $end > $busy_start ) {
                    return true;
                }
            }
        }

        return false;
    }

    /**
     * Busy periods for a staff member sourced from outside the database.
     *
     * Currently Google Calendar; the filter lets other calendar providers (or
     * a site's own logic) contribute without touching the availability engine.
     *
     * @since 4.4.3
     * @param string $date     Y-m-d, site local time.
     * @param int    $staff_id
     * @return array<int, object> Rows shaped like booking rows.
     */
    private static function get_external_busy_slots( $date, $staff_id ) {
        $slots = array();

        if ( class_exists( 'YATOON_Google_Calendar' ) && YATOON_Google_Calendar::is_two_way_enabled() ) {
            foreach ( YATOON_Google_Calendar::get_busy_windows( $staff_id, $date ) as $window ) {
                $slots[] = (object) array(
                    'start_time' => $window['start']->format( 'Y-m-d H:i:s' ),
                    'end_time'   => $window['end']->format( 'Y-m-d H:i:s' ),
                    'external'   => 'google',
                );
            }
        }

        /**
         * Contribute additional external busy windows.
         *
         * @since 4.4.3
         * @param array  $slots    Rows with start_time/end_time in site local time.
         * @param string $date     Y-m-d.
         * @param int    $staff_id
         */
        return (array) apply_filters( 'yatoon_external_busy_slots', $slots, $date, $staff_id );
    }

    /**
     * Public wrapper for get_booked_slots — used by the multi-service booking
     * conflict checker in class-yatoon-ajax.php.
     */
    public static function get_booked_slots_public( $date, $staff_id = null ) {
        return self::get_booked_slots( $date, $staff_id );
    }

    /**
     * Check whether [slot_start, slot_end) conflicts with existing bookings
     */
    private static function has_conflict( $slot_start, $slot_end, $booked ) {
        $local_tz = yatoon_timezone();
        foreach ($booked as $b) {
            $bs = new DateTime($b->start_time, $local_tz);
            $be = new DateTime($b->end_time, $local_tz);
            // Overlap: slot_start < booking_end && slot_end > booking_start
            if ($slot_start < $be && $slot_end > $bs) { return true;
            }
        }
        return false;
    }

    /**
     * Check whether a specific time falls within business hours (used when creating bookings)
     */
    public static function is_valid_slot( $start_at, $service_id, $staff_id = 0, $exclude_booking_id = null, $local_tz = null ) {
        $tz = $local_tz instanceof DateTimeZone ? $local_tz : yatoon_timezone();
        $dt       = new DateTime($start_at, $tz);
        $date     = $dt->format('Y-m-d');
        $time     = $dt->format('H:i');

        // When rescheduling, check the expanded slot list that excludes the current booking
        if ( $exclude_booking_id ) {
            $slots = self::get_available_slots_for_date( $date, $service_id, $staff_id, $exclude_booking_id, $tz );
            foreach ( $slots as $s ) {
                // $s['datetime'] is 'Y-m-d H:i:s' in local TZ
                $slot_time = ( new DateTime( $s['datetime'], $tz ) )->format('H:i');
                if ( $slot_time === $time ) { return true;
                }
            }
            return false;
        }

        $slots = self::generate_slots($date, $service_id, $staff_id);
        foreach ($slots as $s) {
            if ($s['time'] === $time) { return true;
            }
        }
        return false;
    }

    /**
     * Return open/closed status for each day of a week (used to disable closed days in calendar)
     */
    public static function get_week_status( $start_date ) {
        $hours  = self::get_hours();
        $status = array();
        $local_tz = yatoon_timezone();
        $start_dt = new DateTimeImmutable($start_date . ' 12:00:00', $local_tz);
        for ($i = 0; $i < 7; $i++) {
            $day = $start_dt->modify("+$i days");
            $d   = $day->format('Y-m-d');
            $key = strtolower($day->format('D'));
            $status[ $d ] = !empty($hours[ $key ]['open']);
        }
        return $status;
    }

    /**
     * Get all available time slots for a given date, service, and staff.
     * Used by the customer reschedule panel.
     *
     * @param string     $date        'Y-m-d' in local timezone
     * @param int        $service_id
     * @param int        $staff_id
     * @param int|null   $exclude_booking_id  Booking to exclude from conflict check (the one being rescheduled)
     * @param DateTimeZone|null $local_tz Optional booking/location timezone snapshot.
     * @return array  Array of ['display' => '9:00 AM', 'datetime' => 'Y-m-d H:i:s' (local TZ)]
     */
    public static function get_available_slots_for_date( $date, $service_id, $staff_id, $exclude_booking_id = null, $local_tz = null, $duration_override = null ) {
        global $wpdb;

        $local_tz = $local_tz instanceof DateTimeZone ? $local_tz : yatoon_timezone();
        $day_hours = self::get_day_hours( $date );
        if ( ! $day_hours ) {
            return array();
        }

        $open_raw  = $day_hours['start'] ?? '09:00';
        $close_raw = $day_hours['end']   ?? '17:00';

        $open_dt  = new DateTime( $date . ' ' . $open_raw,  $local_tz );
        $close_dt = new DateTime( $date . ' ' . $close_raw, $local_tz );
        $break_start = ! empty( $day_hours['break_start'] ) ? new DateTime( $date . ' ' . $day_hours['break_start'], $local_tz ) : null;
        $break_end = ! empty( $day_hours['break_end'] ) ? new DateTime( $date . ' ' . $day_hours['break_end'], $local_tz ) : null;
        $now      = new DateTime( 'now', $local_tz );
        $now->modify( '+' . intval( get_option( 'yatoon_manage_min_hours_before', 2 ) ) . ' hours' );

        $interval_min = intval( get_option( 'yatoon_slot_interval', 30 ) );
        if ( $interval_min < 5 ) { $interval_min = 30;
        }

        // Get service duration
        $duration_min = $duration_override ? absint( $duration_override ) : intval( $wpdb->get_var( $wpdb->prepare(
            "SELECT duration_minutes FROM {$wpdb->prefix}yatoon_services WHERE id = %d",
            $service_id
        ) ) );
        $duration_min = $duration_min ?: 60;

        // Local-mode bookings are stored as local datetime strings.
        $day_start_local = new DateTime( $date . ' 00:00:00', $local_tz );
        $day_end_local   = new DateTime( $date . ' 23:59:59', $local_tz );

        $exclude_ids = array_values( array_filter( array_map( 'absint', (array) $exclude_booking_id ) ) );
        $exclude_sql = $exclude_ids
            ? ' AND id NOT IN (' . implode( ',', $exclude_ids ) . ')'
            : '';
        $existing = $wpdb->get_results( $wpdb->prepare(
            "SELECT start_time, end_time FROM {$wpdb->prefix}yatoon_bookings
             WHERE staff_id = %d
               AND start_time < %s
               AND end_time   > %s
               AND status NOT IN ('cancelled','cancelled_by_customer','CANCELLED_BY_CUSTOMER','CANCELLED_BY_SELLER','DECLINED')
               {$exclude_sql}",
            $staff_id,
            $day_end_local->format( 'Y-m-d H:i:s' ),
            $day_start_local->format( 'Y-m-d H:i:s' )
        ) );

        $busy = array();
        foreach ( $existing as $row ) {
            $busy_start = new DateTime( $row->start_time, $local_tz );
            $busy_end   = new DateTime( $row->end_time, $local_tz );
            $busy[] = array(
                'start' => $busy_start->getTimestamp(),
                'end'   => $busy_end->getTimestamp(),
            );
        }

        $slots   = array();
        $current = clone $open_dt;

        while ( true ) {
            $slot_end = ( clone $current )->modify( "+{$duration_min} minutes" );
            if ( $slot_end > $close_dt ) { break;
            }

            // Must be in the future (after cutoff)
            if ( $current > $now ) {
                $ts_start = $current->getTimestamp();
                $ts_end   = $slot_end->getTimestamp();

                $conflict = false;
                foreach ( $busy as $b ) {
                    if ( $ts_start < $b['end'] && $ts_end > $b['start'] ) {
                        $conflict = true;
                        break;
                    }
                }

                if ( ! $conflict && $break_start && $break_end && $current < $break_end && $slot_end > $break_start ) { $conflict = true; }

                if ( ! $conflict && self::has_staff_time_off_conflict( $staff_id, $current, $slot_end, $local_tz ) ) {
                    $conflict = true;
                }
                if ( ! $conflict && self::has_external_busy_conflict( $staff_id, $current, $slot_end ) ) { $conflict = true; }
                if ( ! $conflict && class_exists( 'YATOON_Resources' ) && ! YATOON_Resources::is_available( $service_id, $current, $slot_end, $exclude_booking_id ) ) { $conflict = true; }

                if ( ! $conflict ) {
                    $slots[] = array(
                        'display'  => $current->format( 'g:i A' ),
                        'datetime' => $current->format( 'Y-m-d H:i:s' ),
                    );
                }
            }

            $current->modify( "+{$interval_min} minutes" );
        }

        return $slots;
    }
}
