<?php
/**
 * Updated UserController with Fixed Nested Validation
 *
 * @package RadiusTheme\RadiusBooking\Controllers
 */

namespace RadiusTheme\RadiusBooking\Controllers;

use RadiusTheme\RadiusBooking\Core\Api\ApiResponse;
use RadiusTheme\RadiusBooking\Core\Api\Middleware\PermissionMiddleware;
use WP_REST_Request;
use WP_Error;
use RadiusTheme\RadiusBooking\Abstracts\BaseController;
use RadiusTheme\RadiusBooking\Core\Container\Container;
use RadiusTheme\RadiusBooking\Core\Api\Middleware\AuthMiddleware;
use RadiusTheme\RadiusBooking\Core\Api\Validation\Rules\EmailRule;
use RadiusTheme\RadiusBooking\Core\Api\Validation\Rules\RequiredRule;
use RadiusTheme\RadiusBooking\Core\Api\Validation\Rules\ArrayRule;
use RadiusTheme\RadiusBooking\Core\Api\Validation\Rules\NumericRule;
use RadiusTheme\RadiusBooking\Core\Api\Validation\Rules\StringRule;
use RadiusTheme\RadiusBooking\Core\Api\Validation\Rules\DateRule;
use RadiusTheme\RadiusBooking\Core\Api\Validation\Rules\TimeRule;
use RadiusTheme\RadiusBooking\Core\Api\Validation\Rules\BooleanRule;
use RadiusTheme\RadiusBooking\Core\Api\Validation\Rules\InRule;
use RadiusTheme\RadiusBooking\Core\Api\Validation\Rules\MinRule;
use RadiusTheme\RadiusBooking\Core\Api\Validation\Rules\MaxRule;
use RadiusTheme\RadiusBooking\Helpers\SettingsHelper;
use RadiusTheme\RadiusBooking\Models\User;
use RadiusTheme\RadiusBooking\Models\UserProfile;
use RadiusTheme\RadiusBooking\Models\EmployeeService;
use RadiusTheme\RadiusBooking\Models\Location;
use RadiusTheme\RadiusBooking\Repositories\UserRepository;
use RadiusTheme\RadiusBooking\Resources\UserResource;
use RadiusTheme\RadiusBooking\Services\EmployeeServices\UserWeekdayDataService;
use RadiusTheme\RadiusBooking\Services\UserService;
use DateTimeImmutable;

if ( ! defined( 'ABSPATH' ) ) {
	exit; // Exit if accessed directly
}

/**
 * User controller
 */
class UserController extends BaseController {

	/**
	 * UserRepository instance
	 */
	public function __construct() {
		parent::__construct( Container::resolve( UserRepository::class ) );

		 $this->middleware[] = new AuthMiddleware();
		 $this->middleware[] = new PermissionMiddleware( 'manage_options' );
		add_action( 'radius_booking_item_created_user', array( $this, 'handleUserCreated' ), 10, 4 );
		add_action( 'radius_booking_item_updated_user', array( $this, 'handleUserUpdated' ), 10, 3 );

		add_filter( 'radius_booking_show_item_user', array( $this, 'showWithProfile' ), 10, 3 );
		add_filter( 'radius_booking_index_items_user', array( $this, 'showWithProfileIndex' ), 10, 2 );
		add_filter( 'radius_booking_created_item_user', array( $this, 'showAfterCreateOrUpdateUser' ), 10, 3 );
		add_filter( 'radius_booking_updated_item_user', array( $this, 'showAfterCreateOrUpdateUser' ), 10, 3 );
		add_filter( 'radius_booking_index_query_condition_user', array( $this, 'setCondition' ), 10, 3 );
	}

	/**
	 * Get resource type
	 *
	 * @return string
	 */
	protected function getResourceType(): string {
		return 'user';
	}

	/**
	 * Get validation rules based on request parameters and context
	 *
	 * @param object $request The request object containing parameters.
	 * @param string $context The context of the validation, such as 'create' or 'update'.
	 *
	 * @return array An array of validation rules structured by fields.
	 */
	protected function getValidationRules( $request, string $context = 'create' ): array {
		$rules = array(
			// Basic user fields
			'email'      => array( new RequiredRule(), new EmailRule() ),
			'user_type'  => array( new RequiredRule(), new InRule( array( 'employee', 'customer', 'admin' ) ) ),
			'first_name' => array( new RequiredRule(), new StringRule(), new MaxRule( 100 ) ),
			'last_name'  => array( new RequiredRule(), new StringRule(), new MaxRule( 100 ) ),
		);

		if ( $request->get_param( 'user_type' ) === 'customer' ) {
			return $rules;
		}
//		if ( $request->get_param( 'user_type' ) === 'employee' ) {
//			$rules['location_id'] = array( new RequiredRule() );
//		}
		if ( 'create' === $context ) {
			return $rules;
		}
		// Add employee-specific nested validation rules
		if ( 'update' === $context ) {
			$rules = array_merge( $rules, $this->getEmployeeValidationRules( $context ) );
		}

		return $rules;
	}

	/**
	 * Get employee-specific validation rules
	 *
	 * @param string $context Validation context.
	 *
	 * @return array
	 */
	private function getEmployeeValidationRules( $context ): array {
		$data = array(
			// 'services'                             => array( new ArrayRule() ),
			// 'services.*.service_id'                => array( new RequiredRule(), new NumericRule() ),
			// 'services.*.price'                     => array(
			// new RequiredRule(),
			// new NumericRule(),
			// new MinRule( 0 ),
			// ),
			// 'services.*.min_capacity'              => array(
			// new RequiredRule(),
			// new NumericRule(),
			// new MinRule( 1 ),
			// ),
			// 'services.*.max_capacity'              => array(
			// new RequiredRule(),
			// new NumericRule(),
			// new MinRule( 1 ),
			// ),
			// Custom pricing validation - Fixed structure
			// 'services.*.custom_pricing'            => array( new ArrayRule() ),
			// 'services.*.custom_pricing.enabled'    => array( new BooleanRule() ),
			// 'services.*.custom_pricing.durations'  => array( new ArrayRule() ),

			// 'days_off.*.name'                      => array(
			// new RequiredRule(),
			// new StringRule(),
			// new MaxRule( 100 ),
			// ),
			// 'days_off.*.start_date'                => array( new RequiredRule(), new DateRule() ),
			// 'days_off.*.end_date'                  => array( new RequiredRule(), new DateRule() ),
			// 'days_off.*.recurring'                 => array( new BooleanRule() ),
			//
			// 'weekdays'                             => array( new ArrayRule() ),
			// 'weekdays.*.day_index'                 => array( new RequiredRule(), new NumericRule() ),
			// 'weekdays.*.start_time'                => array( new RequiredRule(), new TimeRule() ),
			// 'weekdays.*.end_time'                  => array( new RequiredRule(), new TimeRule() ),
			//
			// 'weekdays.*.time_breaks'               => array( new ArrayRule() ),
			//
			// 'weekdays.*.time_breaks.*.start_time'  => array( new RequiredRule(), new TimeRule() ),
			// 'weekdays.*.time_breaks.*.end_time'    => array( new RequiredRule(), new TimeRule() ),
			// 'weekdays.*.time_periods'              => array( new ArrayRule() ),
			// 'weekdays.*.time_periods.*.start_time' => array( new RequiredRule(), new TimeRule() ),
			// 'weekdays.*.time_periods.*.end_time'   => array( new RequiredRule(), new TimeRule() ),
			//
			// 'weekdays.*.time_periods.*.locations'  => array( new ArrayRule() ),
			// 'weekdays.*.time_periods.*.locations.*.location_id' => array( new RequiredRule(), new NumericRule() ),
			//
			// 'weekdays.*.time_periods.*.services'   => array( new ArrayRule() ),
			// 'weekdays.*.time_periods.*.services.*.service_id' => array( new RequiredRule(), new NumericRule() ),
		);

		$updateData = array(
			// Services validation - Fixed to handle your JSON structure
			'services.*.id' => array( new NumericRule() ),

			// Days off validation - Fixed field name from 'recurring' to 'repeat'
			// 'days_off'                                 => array( new ArrayRule() ),
			// 'days_off.*.id'                            => array( new NumericRule() ),
			// 'days_off.*.user_id'                       => array( new NumericRule() ),
			//
			// Weekdays validation
			// 'weekdays.*.id'                            => array( new NumericRule() ),
			// 'weekdays.*.user_id'                       => array( new NumericRule() ),
			//
			// Time breaks validation
			// 'weekdays.*.time_breaks.*.id'              => array( new NumericRule() ),
			// 'weekdays.*.time_breaks.*.weekday_id'      => array( new NumericRule() ),
			//
			// Time periods validation
			// 'weekdays.*.time_periods.*.id'             => array( new NumericRule() ),
			// 'weekdays.*.time_periods.*.weekday_id'     => array( new NumericRule() ),
			//
			// Locations validation
			// 'weekdays.*.time_periods.*.locations.*.id' => array( new NumericRule() ),
			// 'weekdays.*.time_periods.*.locations.*.weekday_time_period_id' => array( new NumericRule() ),
			//
			// Services in time periods validation
			// 'weekdays.*.time_periods.*.services.*.id'  => array( new NumericRule() ),
			// 'weekdays.*.time_periods.*.services.*.weekday_time_period_id' => array( new NumericRule() ),
		);

		if ( $context === 'update' ) {
			$data = array_merge( $data, $updateData );
		}

		return $data;
	}

	/**
	 * Apply conditional validation rules based on data.
	 *
	 * @param array $rules Base validation rules.
	 * @param array $data Request data.
	 * @param string $context Validation context.
	 *
	 * @return array
	 */
	protected function applyConditionalRules( array $rules, array $data, string $context ): array {
		// Remove service_id requirement if it's not provided for any service
		return $rules;
	}

	/**
	 * Transform an item into a structured array
	 *
	 * @param mixed $item The item to be transformed
	 *
	 * @return array The transformed item as an array
	 */
	protected function transformItem( $item ): array {
		return ( new UserResource() )->transform( $item );
	}

	/**
	 * Transforms a collection of items by sorting them in the specified direction
	 * and converting them into resources.
	 *
	 * @param array $items The collection of items to be transformed.
	 * @param string $direction The sorting direction, either 'ASC' or 'DESC' (default: 'DESC').
	 *
	 * @return array The transformed collection of resources.
	 */
	protected function transformCollection( array $items, string $direction = 'DESC' ): array {
		// Sort original items first
//		usort(
//			$items,
//			function ( $a, $b ) use ( $direction ) {
//				// Use the actual sortable field, e.g., tsta or id
//				$aVal = $a->id;
//				$bVal = $b->id;
//
//				// If it's a date, convert to timestamp
//				if ( strtotime( $aVal ) && strtotime( $bVal ) ) {
//					$aVal = strtotime( $aVal );
//					$bVal = strtotime( $bVal );
//				}
//
//				return 'DESC' === $direction
//					? $bVal <=> $aVal
//					: $aVal <=> $bVal;
//			}
//		);

		// Then transform into resources
		return UserResource::collection( $items );
	}


	/**
	 * Extract profile data based on fillable fields
	 *
	 * @param array $data The input data array containing potential profile information
	 *
	 * @return array The filtered array containing only the fields defined as fillable in the UserProfile model
	 */
	private function extractProfileData( array $data ): array {
		$fillable = ( new UserProfile() )->getFillable();

		return array_intersect_key( $data, array_flip( $fillable ) );
	}

	/**
	 * Handles the event when a user is created.
	 *
	 * @param mixed $data The data associated with the user creation event.
	 * @param mixed $item The item representing the user being created.
	 * @param mixed $request The request object containing additional information.
	 * @param mixed $controller The controller handling this operation.
	 *
	 * @return void
	 */
	public function handleUserCreated( $data, $item, $request, $controller ): void {
		$this->createOrUpdateProfile( $item, $request, true );
	}

	/**
	 * Handle user updated event by creating or updating the user profile.
	 *
	 * @param mixed $item The user item containing relevant data.
	 * @param mixed $request The request object holding additional user update details.
	 * @param mixed $controller The controller instance managing the operation.
	 *
	 * @return void
	 */
	public function handleUserUpdated( $item, $request, $controller ): void {
		$this->createOrUpdateProfile( $item, $request, false );
	}

	/**
	 * Create or update a user profile based on the provided data.
	 *
	 * @param object $item The user item object containing data, including the user ID.
	 * @param WP_REST_Request $request The current REST request, containing JSON parameters.
	 * @param bool $isCreate Indicates whether to create a new profile (true) or update an existing one (false). Default is true.
	 *
	 * @return void
	 * @throws \Exception If an error occurs during the profile creation or update process.
	 */
	private function createOrUpdateProfile( $item, WP_REST_Request $request, bool $isCreate = true ): void {
		if ( ! $item->id ) {
			return;
		}

		$service                = Container::resolve( UserService::class );
		$profileData            = $this->extractProfileData( $request->get_json_params() );
		$profileData['user_id'] = $item->id;

		$item->load( 'profile' );
		if ( $isCreate ) {
			$service->createUserWithProfile( $profileData );
		} else {
			$service->updateUserProfile( $item->id, $profileData );
		}

		if ( ! empty( $profileData['wp_user_id'] ) && 'create_new' === $profileData['wp_user_id'] ) {
			$service->createWpUser( $item->id, $profileData );
		}

		if ( $item->get_type() === 'employee' ) {
			$this->syncEmployeeData( $service, $item->id, $request );
		}

		$item->load( 'services' );
	}

	/**
	 * Synchronize employee data by processing parameters from the request and invoking the appropriate service methods.
	 *
	 * @param UserService $service The service responsible for handling user-related operations.
	 * @param int $user_id The ID of the user whose data is being synchronized.
	 * @param WP_REST_Request $request The request instance containing employee data for synchronization.
	 *
	 * @return void
	 */
	private function syncEmployeeData( UserService $service, int $user_id, WP_REST_Request $request ): void {
		$syncMap = array(
			'services'     => 'createOrUpdateUserServices',
			'days_off'     => 'createOrUpdateUserDaysOff',
			'weekdays'     => 'createOrUpdateUserWeekdays',
			'special_days' => 'createOrUpdateUserSpecialDays',
		);

		foreach ( $syncMap as $key => $method ) {
			$data = $request->get_param( $key );
			if ( is_array( $data ) ) {
				$service->$method( $user_id, $data );
			}
		}
	}

	/**
	 * Display the item with its associated profile.
	 *
	 * @param mixed $item The item to be loaded with its profile.
	 * @param mixed $id The identifier for the item.
	 * @param mixed $controller The controller handling the request.
	 *
	 * @return mixed The item with the loaded profile.
	 */
	public function showWithProfile( $item, $id, $controller ) {
		$item->load( 'profile' );

		return $item;
	}

	/**
	 * Show items with their profile index
	 *
	 * @param array $items An array of items to be processed.
	 * @param mixed $request The request data for additional processing.
	 *
	 * @return array The processed array of items with the 'profile' relationships loaded.
	 */
//	public function showWithProfileIndex( $items, $request ): array {
//		if ( empty( $items ) || ! is_array( $items ) ) {
//			return $items;
//		}
//		$itemIds = array_map( fn( $item ) => $item->id, $items );
//		$items   = $this->repository->findMany( $itemIds );
//
//		return $this->repository->loadRelationships( $items, array( 'profile' ) );
//	}

	public function showWithProfileIndex( $items, $request ): array {
		if ( empty( $items ) || ! is_array( $items ) ) {
			return $items;
		}
		$sortedIds = array_map( fn( $item ) => $item->id, $items );

		$fetched = $this->repository->findMany( $sortedIds );
		$this->repository->loadRelationships( $fetched, array( 'profile' ) );

		// Re-index by ID.
		$indexed = array();
		foreach ( $fetched as $item ) {
			$indexed[ $item->id ] = $item;
		}

		// Restore original sort order.
		$ordered = array();
		foreach ( $sortedIds as $id ) {
			if ( isset( $indexed[ $id ] ) ) {
				$ordered[] = $indexed[ $id ];
			}
		}

		return $ordered;
	}

	/**
	 * Show the user item after it is created or updated.
	 *
	 * @param mixed $item The user item instance.
	 * @param mixed $request The request object.
	 * @param mixed $controller The controller handling the request.
	 *
	 * @return mixed
	 */
	public function showAfterCreateOrUpdateUser( $item, $request, $controller ) {
		$item->load( 'profile' );

		return $item;
	}

	/**
	 * Retrieve the profile information of a user.
	 *
	 * @param WP_REST_Request $request The request object containing user ID as a parameter.
	 *
	 * @return WP_Error|WP_REST_Response A response containing the user profile data or an error if the user is not found.
	 */
	public function getProfile( WP_REST_Request $request ) {
		$user_id = $request->get_param( 'user_id' );
		$user    = User::find( $user_id );

		if ( ! $user ) {
			return new WP_Error( 'not_found', 'User not found', array( 'status' => 404 ) );
		}

		$user->load( 'profile', 'appointments' );

		return rest_ensure_response( ( new UserResource() )->transform( $user ) );
	}

	/**
	 * Handles the dashboard data retrieval for a customer.
	 *
	 * @param WP_REST_Request $request The REST request instance containing parameters and data.
	 *
	 * @return mixed
	 */
	public function getDashboard( WP_REST_Request $request ) {
		return $this->applyMiddleware(
			$request,
			function ( $request ) {

				$totalSpend           = $this->repository->getCustomerTotalPaymentAmount();
				$totalCustomers       = $this->repository->getTotalUsersByType();
				$totalActiveCustomers = $this->repository->getTotalUsersByTypeAndStatus();
				$item                 = array(
					'total_spend'            => $totalSpend,
					'total_customers'        => $totalCustomers,
					'total_active_customers' => $totalActiveCustomers,
					'currency'               => array(
						'currency_symbol' => get_rtrb_currency_symbol(),
						'currency'        => get_rtrb_currency(),
					),
				);

				if ( ! $item ) {
					return ApiResponse::notFound()->send();
				}

				/**
				 * Filter the single item before returning it.
				 */
				$item = apply_filters( 'radius_booking_dashboard_customer', $item, $request, $this );

				return ApiResponse::success( $item )->send();
			}
		);
	}

	/**
	 * Handle bulk deletion of resources based on provided IDs.
	 *
	 * @param WP_REST_Request $request The request object containing parameters, including 'ids'.
	 *
	 * @return mixed The response after processing the bulk delete operation.
	 */
	public function bulkDelete( WP_REST_Request $request ) {
		return $this->applyMiddleware(
			$request,
			function ( $request ) {
				$ids = $request->get_param( 'ids' );
				if ( empty( $ids ) || ! is_array( $ids ) || ! array_filter( $ids, 'is_numeric' ) ) {
					return ApiResponse::notFound( 'Invalid IDs' )->send();
				}
				$deletedCount = User::deleteMany( $ids );

				return ApiResponse::success( array( 'deleted_count' => $deletedCount ) )->send();
			}
		);
	}

	/**
	 * Update the status of a user.
	 *
	 * @param WP_REST_Request $request The request object containing user ID and status parameters.
	 *
	 * @return mixed The response after updating the user status.
	 */
	public function updateStatus( WP_REST_Request $request ) {
		return $this->applyMiddleware(
			$request,
			function ( $request ) {
				$user_id = $request->get_param( 'user_id' );
				$status  = $request->get_param( 'status' );
				if ( ! $user_id || ! is_numeric( $user_id ) || ! in_array(
					$status,
					array(
						'active',
						'inactive',
						'pending',
						'draft'
					),
					true
				) ) {
					return ApiResponse::notFound( 'Invalid user ID or status' )->send();
				}

				$user = User::find( $user_id );
				if ( ! $user ) {
					return ApiResponse::notFound( 'User not found' )->send();
				}

				$user->status = $status;
				$user->save();

				return ApiResponse::success( array( 'id' => $user_id ) )->send();
			}
		);
	}

	/**
	 * Set conditions based on a query string and return parsed condition data.
	 *
	 * @param string $where The query string to parse into conditions.
	 * @param mixed $request The current request object or data.
	 * @param mixed $controller The controller handling the request.
	 *
	 * @return array An array of parsed condition data including columns, operators, and values.
	 */
	public function setCondition( $where, $request, $controller ) {
		if ( empty( $where ) ) {
			return array();
		}

		$where     = urldecode( $where );
		$wheres    = explode( '&', $where );
		$whereData = array();
		foreach ( $wheres as $whereItem ) {
			foreach ( $this->allowedOperators as $operator ) {
				if ( strpos( $whereItem, $operator ) !== false ) {
					[ $column, $value ] = explode( $operator, $whereItem, 2 );
					if ( 'all' === $value ) {
						continue;
					}
					if ( ! empty( $column ) && isset( $value ) ) {
						$value = trim( $value );

						// ✅ If value contains comma → use IN operator
						if ( str_contains( $value, ',' ) ) {
							$value       = array_map( 'trim', explode( ',', $value ) );
							$whereData[] = array(
								'column'   => trim( $column ),
								'operator' => 'IN',
								'value'    => $value,
							);
						} else {
							$whereData[] = array(
								'column'   => trim( $column ),
								'operator' => $operator,
								'value'    => $value,
							);
						}
					}
					break;
				}
			}
		}

		return $whereData;
	}


	public function getEmployees( WP_REST_Request $request ) {
		return $this->applyMiddleware(
			$request,
			function ( $request ) {
				$get_employee = $this->repository->getEmployees();

				return ApiResponse::success( $get_employee, 'Resource fetch successfully' )->send();
			}
		);
	}

	/**
	 * Build the enriched employee list used by the [rtrb_employee_list]
	 * shortcode / Elementor widget / Gutenberg block.
	 *
	 * Returns every active employee with avatar, name, phone, location name
	 * and the services they provide (name + price). Employees with no
	 * assigned services are still included (empty services array).
	 *
	 * @return array
	 */
	public function fetchAllEmployees() {
		// service_id => [ name, price ] map from the catalog.
		$svc_map = array();
		try {
			$catalog = ( new ServiceController() )->fetchAllServices();
			if ( is_array( $catalog ) ) {
				foreach ( $catalog as $cat ) {
					foreach ( ( $cat['services'] ?? array() ) as $s ) {
						$svc_map[ (string) $s['id'] ] = array(
							'name'  => $s['name'] ?? '',
							'price' => $s['price'] ?? 0,
						);
					}
				}
			}
		} catch ( \Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
			$svc_map = array();
		}

		$employee_users = User::query()
			->where( 'user_type', 'employee' )
			->where( 'status', 'active' )
			->get();

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

		$user_ids = array_map(
			fn( $u ) => $u->id,
			$employee_users
		);

		$profiles = UserProfile::whereIn( 'user_id', $user_ids )->get();

		// Group service assignments by user_id.
		$assignments = EmployeeService::whereIn( 'user_id', $user_ids )->get();
		$assign_grouped = array();
		foreach ( $assignments as $row ) {
			$sid                              = (string) $row->service_id;
			$assign_grouped[ $row->user_id ][] = array(
				'service_id' => $row->service_id,
				'name'       => $svc_map[ $sid ]['name'] ?? '',
				'price'      => isset( $row->price ) && '' !== $row->price && null !== $row->price
					? $row->price
					: ( $svc_map[ $sid ]['price'] ?? 0 ),
			);
		}

		// Location id => name map.
		$location_ids = array_filter(
			array_map(
				fn( $p ) => $p->location_id,
				$profiles
			)
		);
		$location_map = array();
		if ( ! empty( $location_ids ) ) {
			$locations = Location::whereIn( 'id', array_unique( $location_ids ) )->get();
			foreach ( $locations as $loc ) {
				$location_map[ (string) $loc->id ] = $loc->name;
			}
		}

		// Today's day_index in 1..7 (Mon..Sun), matching the schedule storage
		// format used by EmployeeWeekday / global weeklySchedule.
		$today_day_index = (int) gmdate( 'N', current_time( 'timestamp' ) );

		$out = array();
		foreach ( $profiles as $p ) {
			$image_url = '';
			if ( ! empty( $p->image_id ) ) {
				$image_url = wp_get_attachment_image_url( (int) $p->image_id, 'medium' ) ?: '';
			}

			$services = $assign_grouped[ $p->user_id ] ?? array();

			// Derive schedule summary from effective weekdays (custom -> creator
			// -> global). Falls back gracefully when nothing is configured.
			$schedule = $this->buildEmployeeScheduleSummary( (int) $p->user_id, $today_day_index );

			// "Role" label used in the public card. Falls back to the first
			// assigned service name so the slot doesn't render empty when no
			// dedicated designation field exists yet.
			$role = '';
			if ( ! empty( $services ) ) {
				$role = (string) ( $services[0]['name'] ?? '' );
			}

			$out[] = array(
				'id'              => $p->id,
				'user_id'         => $p->user_id,
				'first_name'      => $p->first_name,
				'last_name'       => $p->last_name,
				'name'            => trim( ( $p->first_name ?? '' ) . ' ' . ( $p->last_name ?? '' ) ),
				'image_url'       => $image_url,
				'phone'           => $p->phone ?? '',
				'location'        => $p->location_id ? ( $location_map[ (string) $p->location_id ] ?? '' ) : '',
				'description'     => wp_strip_all_tags( (string) ( $p->note ?? '' ) ),
				'role'            => $role,
				'services'        => $services,
				'services_count'  => count( $services ),
				'schedule_days'   => $schedule['days'],
				'schedule_hours'  => $schedule['hours_text'],
				'schedule_groups' => $schedule['groups'],
				'today_day_index' => $today_day_index,
				'today_status'    => $schedule['today_status'],
			);
		}

		return $out;
	}

	/**
	 * Build a lightweight schedule summary for the public employee card.
	 *
	 * Returns the per-day enabled flags (Mon..Sun), a per-time-range grouping
	 * so the card can render rows like "Mon, Tue — 9:00 AM - 5:00 PM" /
	 * "Wed - Fri — 10:00 AM - 6:00 PM" when an employee works different hours
	 * on different days, a single hours_text (today's range when working
	 * today, otherwise the first group's range — used by surfaces that only
	 * have room for one line), and the employee's today_status ("available"
	 * if today is on schedule, "off" otherwise). "Busy" detection is
	 * intentionally not derived here to avoid a per-employee appointments
	 * query on listing pages.
	 *
	 * @param int $user_id         Employee user ID.
	 * @param int $today_day_index ISO day-of-week (1=Mon..7=Sun).
	 *
	 * @return array{
	 *     days:array<int,bool>,
	 *     hours_text:string,
	 *     today_status:string,
	 *     groups:array<int,array{days:int[],days_label:string,hours_text:string,start:string,end:string}>
	 * }
	 */
	private function buildEmployeeScheduleSummary( int $user_id, int $today_day_index ): array {
		$weekdays = UserWeekdayDataService::getEffectiveWeekdays( $user_id );

		$days = array(
			1 => false,
			2 => false,
			3 => false,
			4 => false,
			5 => false,
			6 => false,
			7 => false,
		);

		// Map keyed by "start|end" so days that share an identical range are
		// rolled into the same group. day_index order is preserved within a
		// group because we push as we iterate the (sorted) weekdays.
		$grouped     = array();
		$today_start = '';
		$today_end   = '';

		if ( is_array( $weekdays ) ) {
			// Order by day_index so group day lists come out Mon-first.
			usort(
				$weekdays,
				static fn( $a, $b ) => (int) ( $a['day_index'] ?? 0 ) <=> (int) ( $b['day_index'] ?? 0 )
			);

			foreach ( $weekdays as $wd ) {
				$idx = isset( $wd['day_index'] ) ? (int) $wd['day_index'] : 0;
				if ( $idx < 1 || $idx > 7 ) {
					continue;
				}

				$days[ $idx ] = true;

				list( $start, $end ) = $this->extractWeekdayBounds( $wd );

				if ( '' === $start || '' === $end ) {
					continue;
				}

				if ( $idx === $today_day_index ) {
					$today_start = $start;
					$today_end   = $end;
				}

				$key = $start . '|' . $end;
				if ( ! isset( $grouped[ $key ] ) ) {
					$grouped[ $key ] = array(
						'days'  => array(),
						'start' => $start,
						'end'   => $end,
					);
				}
				$grouped[ $key ]['days'][] = $idx;
			}
		}

		$groups = array();
		foreach ( $grouped as $group ) {
			$groups[] = array(
				'days'       => $group['days'],
				'days_label' => $this->formatDaysLabel( $group['days'] ),
				'hours_text' => $this->formatScheduleHoursRange( $group['start'], $group['end'] ),
				'start'      => $group['start'],
				'end'        => $group['end'],
			);
		}

		// Single-line fallback: today's range when on schedule today, else the
		// first group's range. Surfaces with limited vertical space (e.g. the
		// inline icon row) consume this; the full groups list drives the
		// multi-row schedule block.
		if ( '' !== $today_start && '' !== $today_end ) {
			$hours_text = $this->formatScheduleHoursRange( $today_start, $today_end );
		} elseif ( ! empty( $groups ) ) {
			$hours_text = $groups[0]['hours_text'];
		} else {
			$hours_text = '';
		}

		$today_status = $days[ $today_day_index ] ? 'available' : 'off';

		return array(
			'days'         => $days,
			'hours_text'   => $hours_text,
			'today_status' => $today_status,
			'groups'       => $groups,
		);
	}

	/**
	 * Resolve the effective start/end clock strings for a weekday entry.
	 *
	 * Custom employee schedules normally store the times on time_periods only
	 * (the top-level start_time / end_time are blank), so this falls back to
	 * the outermost period bounds when needed.
	 *
	 * @param array $weekday One row from getEffectiveWeekdays().
	 *
	 * @return array{0:string,1:string} [start, end] — either may be empty.
	 */
	private function extractWeekdayBounds( array $weekday ): array {
		$start = (string) ( $weekday['start_time'] ?? '' );
		$end   = (string) ( $weekday['end_time'] ?? '' );

		if ( ( '' !== $start && '' !== $end ) || empty( $weekday['time_periods'] ) || ! is_array( $weekday['time_periods'] ) ) {
			return array( $start, $end );
		}

		foreach ( $weekday['time_periods'] as $tp ) {
			$tps = (string) ( $tp['start_time'] ?? '' );
			$tpe = (string) ( $tp['end_time'] ?? '' );
			if ( '' !== $tps && ( '' === $start || strcmp( $tps, $start ) < 0 ) ) {
				$start = $tps;
			}
			if ( '' !== $tpe && ( '' === $end || strcmp( $tpe, $end ) > 0 ) ) {
				$end = $tpe;
			}
		}

		return array( $start, $end );
	}

	/**
	 * Build a compact, translator-friendly label for a list of day indices.
	 *
	 * Consecutive runs collapse to a range ("Mon - Wed"), non-consecutive days
	 * are comma-joined ("Mon, Wed, Fri"), and mixed cases combine both
	 * ("Mon - Wed, Fri"). Single days return their short name.
	 *
	 * @param int[] $day_indices ISO day indices (1=Mon..7=Sun), expected
	 *                           already sorted ascending.
	 *
	 * @return string
	 */
	private function formatDaysLabel( array $day_indices ): string {
		if ( empty( $day_indices ) ) {
			return '';
		}

		$short_names = array(
			1 => __( 'Mon', 'radius-booking' ),
			2 => __( 'Tue', 'radius-booking' ),
			3 => __( 'Wed', 'radius-booking' ),
			4 => __( 'Thu', 'radius-booking' ),
			5 => __( 'Fri', 'radius-booking' ),
			6 => __( 'Sat', 'radius-booking' ),
			7 => __( 'Sun', 'radius-booking' ),
		);

		// Collapse consecutive indices into runs.
		$runs        = array();
		$run_start   = $day_indices[0];
		$run_end     = $day_indices[0];
		$count       = count( $day_indices );
		for ( $i = 1; $i < $count; $i++ ) {
			if ( $day_indices[ $i ] === $run_end + 1 ) {
				$run_end = $day_indices[ $i ];
				continue;
			}
			$runs[]    = array( $run_start, $run_end );
			$run_start = $day_indices[ $i ];
			$run_end   = $day_indices[ $i ];
		}
		$runs[] = array( $run_start, $run_end );

		$parts = array();
		foreach ( $runs as $run ) {
			list( $a, $b ) = $run;
			if ( $a === $b ) {
				$parts[] = $short_names[ $a ];
			} elseif ( $b === $a + 1 ) {
				// Two-day runs read more naturally as "Mon, Tue" than "Mon - Tue".
				$parts[] = $short_names[ $a ] . ', ' . $short_names[ $b ];
			} else {
				$parts[] = $short_names[ $a ] . ' - ' . $short_names[ $b ];
			}
		}

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

	/**
	 * Format a schedule time range using the plugin's date/time settings.
	 *
	 * Schedule values are stored as wall-clock strings ("HH:MM" or "HH:MM:SS")
	 * already expressed in the site timezone — they are NOT UTC. To prevent a
	 * server/site timezone mismatch from shifting the displayed range (e.g.
	 * "09:00 - 17:00" rendering as "3:00 PM - 11:00 PM" on a UTC server with
	 * an Asia/Dhaka site), each bound is parsed inside `wp_timezone()` and
	 * formatted with `wp_date()` so the input clock value round-trips intact.
	 *
	 * The 12h/24h system is taken from `SettingsHelper::get_setting('dateTime')`
	 * so the public card matches what the rest of the plugin shows.
	 *
	 * @param string $start Start time string as stored in the DB.
	 * @param string $end   End time string as stored in the DB.
	 *
	 * @return string Human readable range, e.g. "9:00 AM - 5:00 PM".
	 */
	private function formatScheduleHoursRange( string $start, string $end ): string {
		if ( '' === $start || '' === $end ) {
			return '';
		}

		$format = $this->getScheduleTimeFormat();
		$tz     = wp_timezone();

		$start_ts = $this->parseScheduleTimeToTimestamp( $start, $tz );
		$end_ts   = $this->parseScheduleTimeToTimestamp( $end, $tz );

		if ( null === $start_ts || null === $end_ts ) {
			return '';
		}

		return wp_date( $format, $start_ts, $tz ) . ' - ' . wp_date( $format, $end_ts, $tz );
	}

	/**
	 * Resolve the PHP date format string to use for schedule times, derived
	 * from the plugin's date/time settings (12h vs 24h). Falls back to "g:i A"
	 * (12h) when the setting is missing so the output stays readable.
	 *
	 * @return string PHP date format.
	 */
	private function getScheduleTimeFormat(): string {
		$date_time = SettingsHelper::get_setting( 'dateTime' );
		$system    = is_array( $date_time ) ? ( $date_time['timeSystem'] ?? '12h' ) : '12h';
		return ( '24h' === $system ) ? 'H:i' : 'g:i A';
	}

	/**
	 * Convert a wall-clock schedule string into a UNIX timestamp anchored in
	 * the site timezone. Accepts "HH:MM" and "HH:MM:SS" inputs. The leading
	 * "!" reset characters on the format strings zero out the date portion so
	 * stray current-date values do not bleed into the comparison.
	 *
	 * @param string        $value Raw schedule time string.
	 * @param \DateTimeZone $tz    Site timezone returned by `wp_timezone()`.
	 *
	 * @return int|null Timestamp on success, null when the value cannot be parsed.
	 */
	private function parseScheduleTimeToTimestamp( string $value, \DateTimeZone $tz ): ?int {
		$value = trim( $value );
		if ( '' === $value ) {
			return null;
		}

		$formats = array( '!H:i:s', '!H:i' );
		foreach ( $formats as $fmt ) {
			$dt = DateTimeImmutable::createFromFormat( $fmt, $value, $tz );
			if ( $dt instanceof DateTimeImmutable ) {
				return $dt->getTimestamp();
			}
		}

		return null;
	}
}
