<?php
/**
 * Appointments admin screen: host for the FullCalendar React island.
 *
 * This is the PHP side of the calendar island host. It registers nothing on its
 * own; the parent menu registrar (class-admin-menu.php) points its Appointments
 * submenu at render(), and wrenchpress.php hooks enqueue() on
 * admin_enqueue_scripts. render() gates on VIEW_APPOINTMENTS and prints the
 * native wp-admin chrome plus the mount container the island (04-05) attaches to.
 * enqueue() loads the built island only on this screen (guarded by a
 * file_exists() check on the generated *.asset.php) and localizes the
 * window.wrenchpressAppointments bootstrap: the wp_rest nonce that secures every
 * REST write, the REST base, the SEARCH base its pickers query as the user types,
 * the manage-capability flag, timezone, business hours, statuses, and a deep-link
 * prefill carrying resolved customer/vehicle LABELS as well as ids. The bootstrap
 * contract is defined here before the island consumes it.
 *
 * @package WrenchPress
 */

defined( 'ABSPATH' ) || exit;

/**
 * Class WrenchPress_Appointments_Admin_Page
 */
class WrenchPress_Appointments_Admin_Page {

	/**
	 * Submenu slug for the Appointments calendar screen. The admin menu registrar
	 * gates the submenu on this slug and page_slugs() lists it so the shared badge
	 * stylesheet enqueues here too.
	 */
	const MENU_SLUG = 'wrenchpress-appointments';

	/**
	 * Script handle for the built calendar island.
	 */
	const SCRIPT_HANDLE = 'wrenchpress-appointments';

	/**
	 * Render the Appointments calendar screen.
	 *
	 * Re-checks the view capability at render time (defense in depth: the submenu
	 * is already gated on the same cap, T-04-12) and wp_die on failure, mirroring
	 * the work-order render gate. Prints native wp-admin chrome and the mount
	 * container the React island attaches to; the calendar itself is drawn client
	 * side once the island boots.
	 *
	 * @return void
	 */
	public static function render() {
		if ( ! current_user_can( WrenchPress_Capabilities::VIEW_APPOINTMENTS ) ) {
			wp_die( esc_html__( 'You do not have permission to do that.', 'pitdesk-repair-crm' ) );
		}

		echo '<div class="wrap">';
		echo '<h1 class="wp-heading-inline">' . esc_html__( 'Appointments', 'pitdesk-repair-crm' ) . '</h1>';
		echo '<hr class="wp-header-end">';
		echo '<div id="wrenchpress-appointments-root"></div>';
		echo '</div>';
	}

	/**
	 * Enqueue the built calendar island and localize its bootstrap on this screen only.
	 *
	 * Hooked on admin_enqueue_scripts. The island loads exclusively on the
	 * Appointments page (matched against the hook suffix like the shared badge
	 * stylesheet does) so its bundle never loads across wp-admin. The generated
	 * *.asset.php is guarded with file_exists() so a missing build degrades to an
	 * empty container instead of a fatal (Pitfall 1). wp-api-fetch is ensured in
	 * the dependency list so the island's apiFetch REST client is available.
	 *
	 * @param string $hook_suffix Current admin page hook suffix.
	 * @return void
	 */
	public static function enqueue( $hook_suffix ) {
		if ( false === strpos( (string) $hook_suffix, self::MENU_SLUG ) ) {
			return;
		}

		$asset_path = WRENCHPRESS_PATH . 'build/appointments/index.asset.php';
		$asset      = file_exists( $asset_path )
			? require $asset_path
			: array(
				'dependencies' => array(),
				'version'      => false,
			);

		$dependencies = ( isset( $asset['dependencies'] ) && is_array( $asset['dependencies'] ) )
			? $asset['dependencies']
			: array();

		// The island talks to the REST namespace through apiFetch; make sure the
		// handle is present even if the current build's asset file omits it.
		if ( ! in_array( 'wp-api-fetch', $dependencies, true ) ) {
			$dependencies[] = 'wp-api-fetch';
		}

		wp_enqueue_script(
			self::SCRIPT_HANDLE,
			WRENCHPRESS_URL . 'build/appointments/index.js',
			$dependencies,
			isset( $asset['version'] ) ? $asset['version'] : false,
			true
		);

		/*
		 * The island's searchable customer and vehicle pickers are wp-components
		 * ComboboxControls. The wp-components SCRIPT handle arrives on its own, in the
		 * generated asset file's dependency array, because wp-scripts externalises the
		 * @wordpress/components import. The STYLESHEET does not: a script dependency
		 * says nothing about styles, and without this the combobox renders as an
		 * unstyled text input with a bare list under it.
		 */
		wp_enqueue_style( 'wp-components' );

		wp_localize_script( self::SCRIPT_HANDLE, 'wrenchpressAppointments', self::bootstrap() );
	}

	/**
	 * Build the window.wrenchpressAppointments bootstrap object.
	 *
	 * This is the interface contract the React island (04-05) consumes. It mints a
	 * fresh wp_rest nonce per request (T-04-15), resolves the site timezone and
	 * first day of week, carries the calendar defaults (business hours, slot
	 * metrics), the four statuses, bounded picker lists, a sanitized deep-link
	 * prefill (T-04-14), and the localized UI copy from the UI-SPEC.
	 *
	 * @return array
	 */
	private static function bootstrap() {
		return array(
			'restBase'         => esc_url_raw( rest_url( 'wrenchpress/v1/appointments' ) ),
			'workOrderUrlBase' => self::work_order_url_base(),
			'nonce'            => wp_create_nonce( 'wp_rest' ),
			'canManage'        => (bool) current_user_can( WrenchPress_Capabilities::MANAGE_APPOINTMENTS ),
			'timezone'         => wp_timezone_string(),
			'firstDay'         => (int) get_option( 'start_of_week', 1 ),
			'businessHours'    => array(
				'start'      => '08:00',
				'end'        => '18:00',
				'daysOfWeek' => array( 1, 2, 3, 4, 5, 6 ),
			),
			'slot'             => array(
				'duration' => '00:30',
				'min'      => '07:00',
				'max'      => '20:00',
			),
			'statuses'         => WrenchPress_Appointments_Repository::statuses(),

			/*
			 * NO PRE-BAKED PICKER LISTS. The two 'per_page' => 500 fetches that used to
			 * live here are GONE, and with them the cap: past 500 customers, the modal
			 * simply could not offer customer 501, and nothing on the screen said so.
			 *
			 * The modal's pickers now search wrenchpress/v1/search/{customers,vehicles} as
			 * the user types. The calendar screen has no no-JavaScript path at all (it is
			 * React or nothing), so unlike the vehicle and work-order forms there is no
			 * server-rendered <select> to bound and no cap notice to disclose.
			 */
			'searchBase'       => esc_url_raw( rest_url( 'wrenchpress/v1' ) ),
			'perPage'          => 20,
			'prefill'          => self::prefill(),
			'i18n'             => self::i18n(),
		);
	}

	/**
	 * Base admin URL for a work order's detail screen.
	 *
	 * The island appends &work_order={id} to this base to open a linked work order
	 * on the existing Work Orders detail screen, reusing the exact page + view
	 * action query args the Phase 3 work-order screen routes on (D-07 reuse).
	 *
	 * @return string
	 */
	private static function work_order_url_base() {
		$slug = class_exists( 'WrenchPress_Work_Orders_Admin_Page' )
			? WrenchPress_Work_Orders_Admin_Page::MENU_SLUG
			: 'wrenchpress-work-orders';

		return esc_url_raw(
			add_query_arg(
				array(
					'page'   => $slug,
					'action' => 'view',
				),
				admin_url( 'admin.php' )
			)
		);
	}

	/**
	 * Sanitized deep-link prefill read from the query string.
	 *
	 * The "Schedule appointment" buttons on the customer and vehicle detail pages
	 * (and a calendar deep link) carry customer/vehicle/date/appointment args. The
	 * ids are absint'd and the date sanitized before they reach the island; the
	 * REST layer re-validates the link server-side before any write (T-04-14). The
	 * appointment id lets the island open that appointment's edit modal on load
	 * (D-12 / UI-SPEC section 5).
	 *
	 * The optional return_lead arg (set by the lead launchpad's Create appointment
	 * step) is absint'd and turned into an internal lead-detail admin URL built
	 * server-side, never a caller-supplied URL, so it cannot become an open
	 * redirect. The island navigates there after a successful create.
	 *
	 * @return array{customer:int,vehicle:int,date:string,appointment:int,returnUrl:string}
	 */
	private static function prefill() {
		// phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only prefill of a create/edit form; no state change, the REST write re-validates.
		$customer    = isset( $_GET['customer'] ) ? absint( wp_unslash( $_GET['customer'] ) ) : 0;
		$vehicle     = isset( $_GET['vehicle'] ) ? absint( wp_unslash( $_GET['vehicle'] ) ) : 0;
		$appointment = isset( $_GET['appointment'] ) ? absint( wp_unslash( $_GET['appointment'] ) ) : 0;
		$date        = isset( $_GET['date'] ) ? sanitize_text_field( wp_unslash( $_GET['date'] ) ) : '';
		$return_lead = isset( $_GET['return_lead'] ) ? absint( wp_unslash( $_GET['return_lead'] ) ) : 0;
		// phpcs:enable WordPress.Security.NonceVerification.Recommended

		$return_url = '';
		if ( $return_lead > 0 ) {
			$leads_slug = class_exists( 'WrenchPress_Leads_Admin_Page' )
				? WrenchPress_Leads_Admin_Page::MENU_SLUG
				: 'wrenchpress-leads';

			$return_url = esc_url_raw(
				add_query_arg(
					array(
						'page'   => $leads_slug,
						'action' => 'view',
						'lead'   => $return_lead,
					),
					admin_url( 'admin.php' )
				)
			);
		}

		/*
		 * THE LABELS, NOT JUST THE IDS. This is hydration, and it is not cosmetic.
		 *
		 * A deep link ("Schedule appointment" from a customer or vehicle detail page) opens
		 * the create modal with these ids already chosen. The pickers get their options from
		 * a SEARCH endpoint now, and a search for the empty string returns the first page of
		 * results, which will not in general contain this particular customer. Core's
		 * ComboboxControl renders the collapsed field from the label of the selected option
		 * IN THE ARRAY IT WAS HANDED, so an id with no matching option renders as an EMPTY
		 * FIELD sitting on top of a real, correct id.
		 *
		 * The user then sees a blank Customer box, picks someone, and the appointment is
		 * booked against the wrong person. So the server, which already resolved these ids,
		 * ships the labels it resolved and the island PINS them into the options array.
		 * Zero round trips, no flash, and it still works if the search endpoint is down.
		 *
		 * ::get() is status-agnostic on purpose: a deep link from an archived customer's
		 * detail page still names them correctly rather than rendering nothing.
		 */
		$customer_label = '';
		$vehicle_label  = '';

		if ( $customer > 0 ) {
			$customer_label = WrenchPress_Customers_Repository::label(
				WrenchPress_Customers_Repository::get( $customer )
			);
		}

		if ( $vehicle > 0 ) {
			$vehicle_row = WrenchPress_Vehicles_Repository::get( $vehicle );

			if ( $vehicle_row ) {
				$vehicle_label = WrenchPress_Vehicles_List_Table::vehicle_label( $vehicle_row );
			}
		}

		return array(
			'customer'      => $customer,
			'customerLabel' => $customer_label,
			'vehicle'       => $vehicle,
			'vehicleLabel'  => $vehicle_label,
			'date'          => $date,
			'appointment'   => $appointment,
			'returnUrl'     => $return_url,
		);
	}

	/**
	 * Localized UI strings for the island, keyed for the FullCalendar toolbar and
	 * the create/edit/warning copy defined in the UI-SPEC copywriting contract.
	 *
	 * @return array<string, string>
	 */
	private static function i18n() {
		return array(
			'month'             => __( 'Month', 'pitdesk-repair-crm' ),
			'week'              => __( 'Week', 'pitdesk-repair-crm' ),
			'day'               => __( 'Day', 'pitdesk-repair-crm' ),
			'today'             => __( 'Today', 'pitdesk-repair-crm' ),
			'schedule'          => __( 'Schedule appointment', 'pitdesk-repair-crm' ),
			'editTitle'         => __( 'Edit appointment', 'pitdesk-repair-crm' ),
			'saveChanges'       => __( 'Save changes', 'pitdesk-repair-crm' ),
			'cancel'            => __( 'Cancel', 'pitdesk-repair-crm' ),
			'createWorkOrder'   => __( 'Create work order', 'pitdesk-repair-crm' ),
			'cancelAppointment' => __( 'Cancel appointment', 'pitdesk-repair-crm' ),
			'keepAppointment'   => __( 'Keep appointment', 'pitdesk-repair-crm' ),
			'deleteAppointment' => __( 'Delete appointment', 'pitdesk-repair-crm' ),
			'saveAnyway'        => __( 'Save anyway', 'pitdesk-repair-crm' ),
			'adjustTime'        => __( 'Adjust time', 'pitdesk-repair-crm' ),
			'undo'              => __( 'Undo', 'pitdesk-repair-crm' ),
			'retry'             => __( 'Retry', 'pitdesk-repair-crm' ),
			'customer'          => __( 'Customer', 'pitdesk-repair-crm' ),
			'vehicle'           => __( 'Vehicle', 'pitdesk-repair-crm' ),
			'startTime'         => __( 'Start date and time', 'pitdesk-repair-crm' ),
			'endTime'           => __( 'End date and time', 'pitdesk-repair-crm' ),
			'reason'            => __( 'Reason / notes', 'pitdesk-repair-crm' ),
			'status'            => __( 'Status', 'pitdesk-repair-crm' ),
			'closeDialog'       => __( 'Close dialog', 'pitdesk-repair-crm' ),
			'emptyHeading'      => __( 'No appointments in this view', 'pitdesk-repair-crm' ),
			'emptyBody'         => __( 'Click any open time slot to schedule an appointment, or use Schedule appointment from a customer or vehicle.', 'pitdesk-repair-crm' ),
			'loading'           => __( 'Loading appointments...', 'pitdesk-repair-crm' ),
			'loadError'         => __( 'Could not load appointments. Refresh the page to try again.', 'pitdesk-repair-crm' ),
			'saveError'         => __( 'Could not save the appointment. Check the fields and try again.', 'pitdesk-repair-crm' ),
			'missingLink'       => __( 'Choose both a customer and a vehicle before scheduling.', 'pitdesk-repair-crm' ),
			'endBeforeStart'    => __( 'The end time must be after the start time.', 'pitdesk-repair-crm' ),
			'searching'         => __( 'Searching...', 'pitdesk-repair-crm' ),
			'noResults'         => __( 'No matches for that search.', 'pitdesk-repair-crm' ),
			'searchError'       => __( 'The search could not be completed. Try again.', 'pitdesk-repair-crm' ),
			/* translators: 1: number of matching rows shown, 2: total number of matching rows. */
			'showingOf'         => __( 'Showing %1$s of %2$s matches. Keep typing to narrow the list.', 'pitdesk-repair-crm' ),
		);
	}
}
