<?php
/**
 * Vehicles admin screen: page router and write handlers.
 *
 * Routes between the list, add form, edit form, and read-only detail view. The
 * parent menu registrar (WrenchPress_Admin_Menu) owns the submenu registration
 * and wires the load hook to handle_load(); this class does not register its own
 * menu. Create, update, and archive are nonce-verified, capability-gated,
 * sanitize every field, and use Post/Redirect/Get on success. Archiving is
 * blocked when the vehicle has an open (non-closed) work order (D-05).
 *
 * @package WrenchPress
 */

defined( 'ABSPATH' ) || exit;

/**
 * Class WrenchPress_Vehicles_Admin_Page
 */
class WrenchPress_Vehicles_Admin_Page {

	/**
	 * Admin page slug. Matches the slug the parent menu registrar reserves.
	 */
	const MENU_SLUG = 'wrenchpress-vehicles';

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

	/*
	 * PICKER_LIMIT IS GONE, AND IT IS NOT COMING BACK.
	 *
	 * It used to cap this screen's owning-customer picker at the first 500 active rows,
	 * because the combobox filtered a client-side list and there was nothing else to filter.
	 * That capped BOTH modes: past 500 customers, customer 501 was not selectable here at
	 * all, and the same 500 was hardcoded into the appointments modal and the work-order
	 * form. Disclosure (np3) stopped it lying; it did not stop it being true.
	 *
	 * The picker now searches the database through wrenchpress/v1/search/customers, so with
	 * JavaScript there is no cap. The only bound left is WrenchPress_Picker_Options::
	 * NOJS_LIMIT, which applies to the server-rendered no-JS select alone, is disclosed on
	 * screen, and is removed from the page by the island when it boots.
	 */

	/**
	 * Nonce action for the create form.
	 */
	const CREATE_NONCE = 'wrenchpress_create_vehicle';

	/**
	 * Nonce action for the edit form.
	 */
	const EDIT_NONCE = 'wrenchpress_edit_vehicle';

	/**
	 * Validation error messages collected during the current request.
	 *
	 * @var string[]
	 */
	private static $form_errors = array();

	/**
	 * Submitted field values to re-populate the form after a validation error.
	 *
	 * @var array<string,string>
	 */
	private static $form_values = array();

	/**
	 * Vehicle id being edited when an update validation error occurs. Zero for the
	 * create flow.
	 *
	 * @var int
	 */
	private static $edit_id = 0;

	/**
	 * Dispatch write actions on page load (before output).
	 *
	 * Archive is a nonce-protected row-action link (GET); create and update are
	 * POST submissions.
	 *
	 * @return void
	 */
	public static function handle_load() {
		$get_action = isset( $_GET['action'] ) ? sanitize_key( wp_unslash( $_GET['action'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- nonce checked in handle_archive().

		if ( 'archive' === $get_action ) {
			self::handle_archive();
			return;
		}

		$method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : '';

		if ( 'POST' !== $method ) {
			return;
		}

		$post_action = isset( $_POST['wrenchpress_action'] ) ? sanitize_key( wp_unslash( $_POST['wrenchpress_action'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked in the specific handler below.

		if ( 'create' === $post_action ) {
			self::handle_create();
		} elseif ( 'update' === $post_action ) {
			self::handle_update();
		}
	}

	/**
	 * Read and sanitize the vehicle fields plus the owning customer id from the
	 * POST body. Text fields go through sanitize_text_field; year, mileage, and
	 * customer_id through absint.
	 *
	 * @return array<string,string|int>
	 */
	private static function read_posted_fields() {
		// phpcs:disable WordPress.Security.NonceVerification.Missing -- callers verify the nonce before invoking this.
		return array(
			'customer_id'   => isset( $_POST['customer_id'] ) ? absint( wp_unslash( $_POST['customer_id'] ) ) : 0,
			'make'          => isset( $_POST['make'] ) ? sanitize_text_field( wp_unslash( $_POST['make'] ) ) : '',
			'model'         => isset( $_POST['model'] ) ? sanitize_text_field( wp_unslash( $_POST['model'] ) ) : '',
			'year'          => isset( $_POST['year'] ) ? absint( wp_unslash( $_POST['year'] ) ) : 0,
			'vin'           => isset( $_POST['vin'] ) ? sanitize_text_field( wp_unslash( $_POST['vin'] ) ) : '',
			'plate'         => isset( $_POST['plate'] ) ? sanitize_text_field( wp_unslash( $_POST['plate'] ) ) : '',
			'mileage'       => isset( $_POST['mileage'] ) ? WrenchPress_Reminders_Repository::mileage_to_canonical( absint( wp_unslash( $_POST['mileage'] ) ), WrenchPress_Settings_Page::mileage_unit() ) : 0,
			'fuel_type'     => isset( $_POST['fuel_type'] ) ? sanitize_text_field( wp_unslash( $_POST['fuel_type'] ) ) : '',
			'transmission'  => isset( $_POST['transmission'] ) ? sanitize_text_field( wp_unslash( $_POST['transmission'] ) ) : '',
			'color'         => isset( $_POST['color'] ) ? sanitize_text_field( wp_unslash( $_POST['color'] ) ) : '',
			'engine'        => isset( $_POST['engine'] ) ? sanitize_text_field( wp_unslash( $_POST['engine'] ) ) : '',
			'engine_number' => isset( $_POST['engine_number'] ) ? sanitize_text_field( wp_unslash( $_POST['engine_number'] ) ) : '',
		);
		// phpcs:enable WordPress.Security.NonceVerification.Missing
	}

	/**
	 * Validate and insert a new vehicle (PRG on success).
	 *
	 * @return void
	 */
	private static function handle_create() {
		check_admin_referer( self::CREATE_NONCE );

		if ( ! current_user_can( WrenchPress_Capabilities::CREATE_VEHICLES ) ) {
			wp_die( esc_html__( 'You do not have permission to do that.', 'pitdesk-repair-crm' ) );
		}

		$values = self::read_posted_fields();
		$errors = self::validate( $values );

		if ( ! empty( $errors ) ) {
			self::$form_errors = $errors;
			self::$form_values = $values;
			return;
		}

		WrenchPress_Vehicles_Repository::create( $values );

		self::redirect_to_list( 'created' );
	}

	/**
	 * Validate and update an existing vehicle (PRG on success).
	 *
	 * @return void
	 */
	private static function handle_update() {
		check_admin_referer( self::EDIT_NONCE );

		if ( ! current_user_can( WrenchPress_Capabilities::EDIT_VEHICLES ) ) {
			wp_die( esc_html__( 'You do not have permission to do that.', 'pitdesk-repair-crm' ) );
		}

		$id = isset( $_POST['vehicle'] ) ? absint( wp_unslash( $_POST['vehicle'] ) ) : 0;

		$values = self::read_posted_fields();
		$errors = self::validate( $values );

		if ( 0 === $id ) {
			$errors[] = __( 'That vehicle could not be found.', 'pitdesk-repair-crm' );
		}

		if ( ! empty( $errors ) ) {
			self::$form_errors = $errors;
			self::$form_values = $values;
			self::$edit_id     = $id;
			return;
		}

		WrenchPress_Vehicles_Repository::update( $id, $values );

		self::redirect_to_list( 'updated' );
	}

	/**
	 * Verify the per-row nonce and soft-delete (archive) a vehicle (PRG).
	 *
	 * The archive is blocked when the vehicle still has an open (non-closed) work
	 * order (D-05): a job in flight must not lose the vehicle out from under it.
	 *
	 * @return void
	 */
	private static function handle_archive() {
		$id = isset( $_GET['vehicle'] ) ? absint( wp_unslash( $_GET['vehicle'] ) ) : 0;

		check_admin_referer( 'wrenchpress_archive_vehicle_' . $id );

		if ( ! current_user_can( WrenchPress_Capabilities::DELETE_VEHICLES ) ) {
			wp_die( esc_html__( 'You do not have permission to do that.', 'pitdesk-repair-crm' ) );
		}

		if ( $id > 0 && WrenchPress_Work_Orders_Repository::has_open( $id ) ) {
			self::redirect_to_list( 'archive_blocked' );
		}

		if ( $id > 0 ) {
			WrenchPress_Vehicles_Repository::archive( $id );
		}

		self::redirect_to_list( 'archived' );
	}

	/**
	 * Redirect to the list with a status flag and exit (PRG).
	 *
	 * @param string $flag Query flag: created, updated, archived, or
	 *                     archive_blocked.
	 * @return void
	 */
	private static function redirect_to_list( $flag ) {
		wp_safe_redirect(
			add_query_arg(
				array(
					'page' => self::MENU_SLUG,
					$flag  => 1,
				),
				admin_url( 'admin.php' )
			)
		);
		exit;
	}

	/**
	 * Server-side validation for the vehicle form. Make and model are required
	 * and the owning customer must be a real, non-zero customer (D-04). Everything
	 * else is optional.
	 *
	 * @param array<string,string|int> $values Sanitized field values.
	 * @return string[] Error messages (empty when valid).
	 */
	private static function validate( array $values ) {
		$errors = array();

		if ( '' === (string) $values['make'] ) {
			$errors[] = __( 'Make is required.', 'pitdesk-repair-crm' );
		}

		if ( '' === (string) $values['model'] ) {
			$errors[] = __( 'Model is required.', 'pitdesk-repair-crm' );
		}

		$customer_id = isset( $values['customer_id'] ) ? absint( $values['customer_id'] ) : 0;

		if ( 0 === $customer_id ) {
			$errors[] = __( 'Choose the customer who owns this vehicle.', 'pitdesk-repair-crm' );
		} elseif ( ! WrenchPress_Customers_Repository::get( $customer_id ) ) {
			$errors[] = __( 'That customer could not be found.', 'pitdesk-repair-crm' );
		}

		return $errors;
	}

	/**
	 * Route the page to the list, add form, edit form, or read-only view.
	 *
	 * @return void
	 */
	public static function render() {
		if ( ! current_user_can( WrenchPress_Capabilities::VIEW_VEHICLES ) ) {
			wp_die( esc_html__( 'You do not have permission to do that.', 'pitdesk-repair-crm' ) );
		}

		$action = isset( $_GET['action'] ) ? sanitize_key( wp_unslash( $_GET['action'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only view routing.

		if ( 'view' === $action ) {
			self::render_view();
			return;
		}

		if ( 'edit' === $action || self::$edit_id > 0 ) {
			self::render_edit_form();
			return;
		}

		if ( 'add' === $action || ! empty( self::$form_errors ) ) {
			self::render_add_form();
			return;
		}

		self::render_list();
	}

	/**
	 * Render the vehicles list screen.
	 *
	 * @return void
	 */
	private static function render_list() {
		$list_table = new WrenchPress_Vehicles_List_Table();
		$list_table->prepare_items();

		$add_url = add_query_arg(
			array(
				'page'   => self::MENU_SLUG,
				'action' => 'add',
			),
			admin_url( 'admin.php' )
		);

		echo '<div class="wrap">';
		echo '<h1 class="wp-heading-inline">' . esc_html__( 'Vehicles', 'pitdesk-repair-crm' ) . '</h1>';

		if ( current_user_can( WrenchPress_Capabilities::CREATE_VEHICLES ) ) {
			echo ' <a href="' . esc_url( $add_url ) . '" class="page-title-action">' . esc_html__( 'Add Vehicle', 'pitdesk-repair-crm' ) . '</a>';
		}

		echo '<hr class="wp-header-end">';

		self::maybe_render_notice();

		echo '<form method="get">';
		echo '<input type="hidden" name="page" value="' . esc_attr( self::MENU_SLUG ) . '" />';
		$list_table->search_box( __( 'Search Vehicles', 'pitdesk-repair-crm' ), 'wrenchpress-vehicle-search' );
		$list_table->display();
		echo '</form>';

		echo '</div>';
	}

	/**
	 * Render the success / blocked admin notice for the PRG flags.
	 *
	 * @return void
	 */
	private static function maybe_render_notice() {
		// phpcs:disable WordPress.Security.NonceVerification.Recommended -- status flags come from our own PRG redirects.
		if ( isset( $_GET['created'] ) ) {
			$message = __( 'Vehicle added.', 'pitdesk-repair-crm' );
			$class   = 'notice-success';
		} elseif ( isset( $_GET['updated'] ) ) {
			$message = __( 'Vehicle updated.', 'pitdesk-repair-crm' );
			$class   = 'notice-success';
		} elseif ( isset( $_GET['archived'] ) ) {
			$message = __( 'Vehicle archived.', 'pitdesk-repair-crm' );
			$class   = 'notice-success';
		} elseif ( isset( $_GET['archive_blocked'] ) ) {
			$message = __( 'This vehicle has an open work order and cannot be archived.', 'pitdesk-repair-crm' );
			$class   = 'notice-error';
		} else {
			return;
		}
		// phpcs:enable WordPress.Security.NonceVerification.Recommended

		echo '<div class="notice ' . esc_attr( $class ) . ' is-dismissible"><p>' . esc_html( $message ) . '</p></div>';
	}

	/**
	 * Render the add-vehicle form.
	 *
	 * @return void
	 */
	private static function render_add_form() {
		if ( ! current_user_can( WrenchPress_Capabilities::CREATE_VEHICLES ) ) {
			wp_die( esc_html__( 'You do not have permission to do that.', 'pitdesk-repair-crm' ) );
		}

		$values = self::form_values_with_defaults( self::$form_values );

		self::render_form( false, 0, $values );
	}

	/**
	 * Render the edit-vehicle form, prefilled from the record (or from the
	 * preserved values after a validation error).
	 *
	 * @return void
	 */
	private static function render_edit_form() {
		if ( ! current_user_can( WrenchPress_Capabilities::EDIT_VEHICLES ) ) {
			wp_die( esc_html__( 'You do not have permission to do that.', 'pitdesk-repair-crm' ) );
		}

		if ( self::$edit_id > 0 ) {
			$id     = self::$edit_id;
			$values = self::form_values_with_defaults( self::$form_values );
		} else {
			$id      = isset( $_GET['vehicle'] ) ? absint( wp_unslash( $_GET['vehicle'] ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only prefill of an edit form.
			$vehicle = $id > 0 ? WrenchPress_Vehicles_Repository::get( $id ) : null;

			if ( ! $vehicle ) {
				echo '<div class="wrap"><h1 class="wp-heading-inline">' . esc_html__( 'Edit Vehicle', 'pitdesk-repair-crm' ) . '</h1>';
				echo '<hr class="wp-header-end">';
				echo '<div class="notice notice-error"><p>' . esc_html__( 'That vehicle could not be found.', 'pitdesk-repair-crm' ) . '</p></div>';
				echo '<p><a href="' . esc_url( self::list_url() ) . '" class="button">' . esc_html__( 'Back to Vehicles', 'pitdesk-repair-crm' ) . '</a></p></div>';
				return;
			}

			$values = self::form_values_with_defaults(
				array(
					'customer_id'   => (int) $vehicle->customer_id,
					'make'          => $vehicle->make,
					'model'         => $vehicle->model,
					'year'          => (int) $vehicle->year,
					'vin'           => $vehicle->vin,
					'plate'         => $vehicle->plate,
					'mileage'       => (int) $vehicle->mileage,
					'fuel_type'     => $vehicle->fuel_type,
					'transmission'  => $vehicle->transmission,
					'color'         => $vehicle->color,
					'engine'        => $vehicle->engine,
					'engine_number' => $vehicle->engine_number,
				)
			);
		}

		self::render_form( true, $id, $values );
	}

	/**
	 * Render a read-only detail view of a single vehicle, followed by its full
	 * service history (the history block is appended in render_service_history()).
	 *
	 * @return void
	 */
	private static function render_view() {
		$id      = isset( $_GET['vehicle'] ) ? absint( wp_unslash( $_GET['vehicle'] ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only view.
		$vehicle = $id > 0 ? WrenchPress_Vehicles_Repository::get( $id ) : null;

		echo '<div class="wrap">';
		echo '<h1 class="wp-heading-inline">' . esc_html__( 'View Vehicle', 'pitdesk-repair-crm' ) . '</h1>';

		if ( $vehicle && current_user_can( WrenchPress_Capabilities::EDIT_VEHICLES ) ) {
			$edit_url = add_query_arg(
				array(
					'page'    => self::MENU_SLUG,
					'action'  => 'edit',
					'vehicle' => (int) $vehicle->id,
				),
				admin_url( 'admin.php' )
			);
			echo ' <a href="' . esc_url( $edit_url ) . '" class="page-title-action">' . esc_html__( 'Edit', 'pitdesk-repair-crm' ) . '</a>';
		}

		if ( $vehicle && current_user_can( WrenchPress_Capabilities::MANAGE_APPOINTMENTS ) ) {
			$schedule_url = add_query_arg(
				array(
					'page'     => 'wrenchpress-appointments',
					'customer' => (int) $vehicle->customer_id,
					'vehicle'  => (int) $vehicle->id,
				),
				admin_url( 'admin.php' )
			);
			echo ' <a href="' . esc_url( $schedule_url ) . '" class="page-title-action">' . esc_html__( 'Schedule appointment', 'pitdesk-repair-crm' ) . '</a>';
		}

		echo '<hr class="wp-header-end">';

		if ( ! $vehicle ) {
			echo '<div class="notice notice-error"><p>' . esc_html__( 'That vehicle could not be found.', 'pitdesk-repair-crm' ) . '</p></div>';
			echo '<p><a href="' . esc_url( self::list_url() ) . '" class="button">' . esc_html__( 'Back to Vehicles', 'pitdesk-repair-crm' ) . '</a></p></div>';
			return;
		}

		$customer      = WrenchPress_Customers_Repository::get( (int) $vehicle->customer_id );
		$customer_name = $customer ? trim( sprintf( '%s, %s', $customer->last_name, $customer->first_name ), ', ' ) : '';

		$unit         = WrenchPress_Settings_Page::mileage_unit();
		$mileage_cell = ( (int) $vehicle->mileage > 0 )
			? number_format_i18n( WrenchPress_Reminders_Repository::mileage_to_display( (int) $vehicle->mileage, $unit ) ) . ' ' . $unit
			: '';

		$rows = array(
			__( 'Customer', 'pitdesk-repair-crm' )      => $customer_name,
			__( 'Make', 'pitdesk-repair-crm' )          => $vehicle->make,
			__( 'Model', 'pitdesk-repair-crm' )         => $vehicle->model,
			__( 'Year', 'pitdesk-repair-crm' )          => ( (int) $vehicle->year > 0 ) ? (string) (int) $vehicle->year : '',
			__( 'VIN', 'pitdesk-repair-crm' )           => $vehicle->vin,
			__( 'Plate', 'pitdesk-repair-crm' )         => $vehicle->plate,
			__( 'Mileage', 'pitdesk-repair-crm' )       => $mileage_cell,
			__( 'Fuel type', 'pitdesk-repair-crm' )     => $vehicle->fuel_type,
			__( 'Transmission', 'pitdesk-repair-crm' )  => $vehicle->transmission,
			__( 'Color', 'pitdesk-repair-crm' )         => $vehicle->color,
			__( 'Engine', 'pitdesk-repair-crm' )        => $vehicle->engine,
			__( 'Engine number', 'pitdesk-repair-crm' ) => $vehicle->engine_number,
			__( 'Date added', 'pitdesk-repair-crm' )    => self::format_datetime( $vehicle->created_at ),
			__( 'Last updated', 'pitdesk-repair-crm' )  => self::format_datetime( $vehicle->updated_at ),
		);

		echo '<table class="form-table" role="presentation"><tbody>';
		foreach ( $rows as $label => $value ) {
			echo '<tr><th scope="row">' . esc_html( $label ) . '</th><td>' . nl2br( esc_html( (string) $value ) ) . '</td></tr>';
		}
		echo '</tbody></table>';

		self::render_service_history( (int) $vehicle->id );

		echo '<p><a href="' . esc_url( self::list_url() ) . '" class="button">' . esc_html__( 'Back to Vehicles', 'pitdesk-repair-crm' ) . '</a></p>';
		echo '</div>';
	}

	/**
	 * Render the per-vehicle service-history block (VEH-02, D-16/D-17).
	 *
	 * Lists every work order for the vehicle newest-first across all statuses as
	 * summary rows: work-order number (#<id>, D-10), created date, a status badge,
	 * and the live grand total. Each row links to the Work Orders detail screen;
	 * there is no inline expansion (D-17). An empty state shows when the vehicle
	 * has no work orders. Every value is escaped at the output boundary.
	 *
	 * @param int $vehicle_id Vehicle id.
	 * @return void
	 */
	private static function render_service_history( $vehicle_id ) {
		$vehicle_id  = (int) $vehicle_id;
		$work_orders = WrenchPress_Work_Orders_Repository::list_for_vehicle( $vehicle_id );
		$statuses    = WrenchPress_Work_Orders_Repository::statuses();

		echo '<h2>' . esc_html__( 'Service history', 'pitdesk-repair-crm' ) . '</h2>';

		if ( empty( $work_orders ) ) {
			echo '<p>' . esc_html__( 'No work orders yet for this vehicle.', 'pitdesk-repair-crm' ) . '</p>';
			return;
		}

		echo '<table class="wp-list-table widefat fixed striped"><thead><tr>';
		echo '<th scope="col">' . esc_html__( 'Work order', 'pitdesk-repair-crm' ) . '</th>';
		echo '<th scope="col">' . esc_html__( 'Date', 'pitdesk-repair-crm' ) . '</th>';
		echo '<th scope="col">' . esc_html__( 'Status', 'pitdesk-repair-crm' ) . '</th>';
		echo '<th scope="col">' . esc_html__( 'Total', 'pitdesk-repair-crm' ) . '</th>';
		echo '</tr></thead><tbody>';

		foreach ( $work_orders as $work_order ) {
			$wo_id  = (int) $work_order->id;
			$slug   = (string) $work_order->status;
			$label  = isset( $statuses[ $slug ] ) ? $statuses[ $slug ] : $slug;
			$totals = WrenchPress_Work_Order_Items_Repository::totals( $wo_id, $work_order->tax_rate );
			$total  = number_format( (float) $totals['grand_total'], 2 );

			$view_url = add_query_arg(
				array(
					'page'       => 'wrenchpress-work-orders',
					'action'     => 'view',
					'work_order' => $wo_id,
				),
				admin_url( 'admin.php' )
			);

			echo '<tr>';
			echo '<td><a href="' . esc_url( $view_url ) . '">' . esc_html( '#' . $wo_id ) . '</a></td>';
			echo '<td>' . esc_html( self::format_datetime( $work_order->created_at ) ) . '</td>';
			echo '<td><span class="wrenchpress-status wrenchpress-status--' . esc_attr( $slug ) . '">' . esc_html( $label ) . '</span></td>';
			echo '<td>' . esc_html( $total ) . '</td>';
			echo '</tr>';
		}

		echo '</tbody></table>';
	}

	/**
	 * Shared add/edit form renderer. The owning-customer picker is visually
	 * separated from the vehicle detail fields (D-03).
	 *
	 * @param bool                     $is_edit    Whether this is the edit flow.
	 * @param int                      $vehicle_id Vehicle id (edit flow only).
	 * @param array<string,string|int> $values     Field values to prefill.
	 * @return void
	 */
	private static function render_form( $is_edit, $vehicle_id, array $values ) {
		$list_url     = self::list_url();
		$title        = $is_edit ? __( 'Edit Vehicle', 'pitdesk-repair-crm' ) : __( 'Add Vehicle', 'pitdesk-repair-crm' );
		$submit_label = $is_edit ? __( 'Save Changes', 'pitdesk-repair-crm' ) : __( 'Add Vehicle', 'pitdesk-repair-crm' );
		$nonce_action = $is_edit ? self::EDIT_NONCE : self::CREATE_NONCE;
		$action_value = $is_edit ? 'update' : 'create';

		echo '<div class="wrap">';
		echo '<h1 class="wp-heading-inline">' . esc_html( $title ) . '</h1>';
		echo '<hr class="wp-header-end">';

		if ( ! empty( self::$form_errors ) ) {
			echo '<div class="notice notice-error"><ul>';
			foreach ( self::$form_errors as $error ) {
				echo '<li>' . esc_html( $error ) . '</li>';
			}
			echo '</ul></div>';
		}

		echo '<form method="post" action="' . esc_url( $list_url ) . '">';
		wp_nonce_field( $nonce_action );
		echo '<input type="hidden" name="wrenchpress_action" value="' . esc_attr( $action_value ) . '" />';

		if ( $is_edit ) {
			echo '<input type="hidden" name="vehicle" value="' . esc_attr( (string) $vehicle_id ) . '" />';
		}

		// Owner section: which customer this vehicle belongs to.
		echo '<h2>' . esc_html__( 'Owner', 'pitdesk-repair-crm' ) . '</h2>';
		echo '<table class="form-table" role="presentation"><tbody>';
		self::customer_row( (int) $values['customer_id'] );
		echo '</tbody></table>';

		// Vehicle details section, visually separated from the owner picker (D-03).
		$unit            = WrenchPress_Settings_Page::mileage_unit();
		$mileage_display = ( (int) $values['mileage'] > 0 )
			? (string) WrenchPress_Reminders_Repository::mileage_to_display( (int) $values['mileage'], $unit )
			: '';

		echo '<h2>' . esc_html__( 'Vehicle details', 'pitdesk-repair-crm' ) . '</h2>';
		echo '<table class="form-table" role="presentation"><tbody>';
		self::text_row( 'make', __( 'Make', 'pitdesk-repair-crm' ), (string) $values['make'], true );
		self::text_row( 'model', __( 'Model', 'pitdesk-repair-crm' ), (string) $values['model'], true );
		self::text_row( 'year', __( 'Year', 'pitdesk-repair-crm' ), ( (int) $values['year'] > 0 ) ? (string) (int) $values['year'] : '', false, 'number' );
		self::text_row( 'vin', __( 'VIN', 'pitdesk-repair-crm' ), (string) $values['vin'], false );
		self::text_row( 'plate', __( 'Plate', 'pitdesk-repair-crm' ), (string) $values['plate'], false );
		self::text_row( 'mileage', __( 'Mileage', 'pitdesk-repair-crm' ), $mileage_display, false, 'number', $unit );
		self::text_row( 'fuel_type', __( 'Fuel type', 'pitdesk-repair-crm' ), (string) $values['fuel_type'], false );
		self::text_row( 'transmission', __( 'Transmission', 'pitdesk-repair-crm' ), (string) $values['transmission'], false );
		self::text_row( 'color', __( 'Color', 'pitdesk-repair-crm' ), (string) $values['color'], false );
		// Two distinct fields, deliberately adjacent and distinctly labelled: Engine
		// is the engine SPEC ("2.0L Turbo"); Engine number is the engine SERIAL
		// number stamped on the block. Both optional. Neither is the other.
		self::text_row( 'engine', __( 'Engine', 'pitdesk-repair-crm' ), (string) $values['engine'], false );
		self::text_row( 'engine_number', __( 'Engine number', 'pitdesk-repair-crm' ), (string) $values['engine_number'], false );
		echo '</tbody></table>';

		echo '<p class="submit">';
		echo '<button type="submit" class="button button-primary">' . esc_html( $submit_label ) . '</button> ';
		echo '<a href="' . esc_url( $list_url ) . '" class="button button-link">' . esc_html__( 'Back to Vehicles', 'pitdesk-repair-crm' ) . '</a>';
		echo '</p>';

		echo '</form>';
		echo '</div>';
	}

	/**
	 * Render the owning-customer select row, populated from active customers.
	 *
	 * THE <select> IS THE VALUE SINK, AND IT STAYS ONE. The customer-picker island
	 * progressively enhances this row into a searchable ComboboxControl, but it does
	 * NOT introduce a hidden input of its own: it writes straight back into this
	 * select, which keeps its name, its id and its option list. One name, one value,
	 * one POST contract, and read_posted_fields() is untouched by the enhancement.
	 *
	 * `required` is emitted here and is NOT negotiable: with JavaScript off this is a
	 * visible, focusable control and the browser natively blocks an empty submit and
	 * reports it. The island STRIPS the attribute when it takes over, because a
	 * required control the browser cannot focus (this select ends up hidden) blocks
	 * submit and then cannot report the failure, so the form silently does nothing.
	 * The split is deliberate: native validation for the native control, an announced
	 * client guard for the enhanced one. The server's validate() is the real gate in
	 * both cases and rejects customer_id 0 either way.
	 *
	 * @param int $selected Currently selected customer id.
	 * @return void
	 */
	private static function customer_row( $selected ) {
		$selected = (int) $selected;
		$id       = 'wrenchpress_customer_id';

		/*
		 * The bounded no-JS list, WITH THE SELECTED ROW INJECTED. That injection is a bug
		 * fix (see WrenchPress_Picker_Options): the saved owner may be ARCHIVED, in which
		 * case list_active() does not return them, no option matched, and this form used to
		 * come up showing the placeholder over a perfectly good customer_id. Saving from
		 * that state reassigned the vehicle or dropped its owner, silently.
		 */
		$customers = WrenchPress_Picker_Options::customer_options( $selected );
		$total     = WrenchPress_Picker_Options::customer_total();

		echo '<tr><th scope="row"><label for="' . esc_attr( $id ) . '">' . esc_html__( 'Customer', 'pitdesk-repair-crm' );
		echo ' <span class="description">' . esc_html__( '(required)', 'pitdesk-repair-crm' ) . '</span>';
		echo '</label></th><td>';

		// Where the island mounts. Empty, and it stays empty with JavaScript off.
		echo '<div class="wrenchpress-picker-mount"></div>';

		echo '<select name="customer_id" id="' . esc_attr( $id ) . '" data-wrenchpress-picker="customer" required>';

		/*
		 * THE PLACEHOLDER'S VALUE MUST BE THE EMPTY STRING, AND value="0" IS NOT A
		 * HARMLESS SPELLING OF IT.
		 *
		 * A <select required> is invalid only when the selected option's value is "".
		 * With value="0" the placeholder is a NON-EMPTY value, the constraint is
		 * satisfied the moment the page loads, and `required` never fires: measured,
		 * validity.valid came back TRUE with the placeholder selected. The attribute
		 * was decoration. The no-JS path did not block an empty submit at all; it
		 * round-tripped to the server, which caught it in validate() and re-rendered.
		 *
		 * "" makes the native constraint real, which is what the whole progressive
		 * enhancement rests on: with JavaScript off the browser now blocks and reports
		 * on a control it can actually focus. The POST contract is unchanged, because
		 * read_posted_fields() runs absint() and absint( '' ) === absint( '0' ) === 0,
		 * and validate() rejects 0 exactly as before.
		 */
		echo '<option value="">' . esc_html__( '- Select a customer -', 'pitdesk-repair-crm' ) . '</option>';

		foreach ( $customers as $customer ) {
			echo '<option value="' . esc_attr( (string) (int) $customer['value'] ) . '"' . selected( $selected, (int) $customer['value'], false ) . '>' . esc_html( $customer['label'] ) . '</option>';
		}

		echo '</select>';
		echo '<p class="description">' . esc_html__( 'The customer who owns this vehicle. Type to search every customer in the shop.', 'pitdesk-repair-crm' ) . '</p>';

		/*
		 * The no-JS bound, disclosed HONESTLY and only where it is true.
		 *
		 * Server-rendered rather than <noscript>, because the island's enqueue is guarded on
		 * file_exists() of its built asset file: a missing or stale build is a real state in
		 * which JavaScript is on and the enhancement still does not run, and a <noscript>
		 * notice would be hidden in exactly that state. The island removes this element on
		 * boot, which is correct in every case.
		 */
		$cap_notice = WrenchPress_Picker_Options::cap_notice(
			count( $customers ),
			$total,
			add_query_arg(
				array( 'page' => WrenchPress_Customers_Admin_Page::MENU_SLUG ),
				admin_url( 'admin.php' )
			),
			'customers'
		);

		// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- cap_notice() escapes every interpolated value at its own boundary and returns fixed markup.
		echo $cap_notice;

		echo '</td></tr>';
	}

	/**
	 * Enqueue the built customer-picker island on the Vehicles screen only.
	 *
	 * Hooked on admin_enqueue_scripts, and gated on the hook suffix, exactly like the
	 * appointments island. The generated *.asset.php is file_exists()-guarded so a
	 * missing build degrades to the plain server-rendered <select> instead of a fatal.
	 *
	 * Deliberately NOT gated on the `action` query arg. After a validation error the
	 * form re-renders on a POST that carries no `action` GET arg, so an action gate
	 * would strip the enhancement from precisely the screen the user has just failed
	 * on. The gate is the screen; the script itself no-ops when no
	 * [data-wrenchpress-picker] element is on the page (the list and detail views).
	 *
	 * No new nonce and no new capability surface: this reads and writes nothing. The
	 * render is already gated on the vehicle caps, and every write already runs
	 * check_admin_referer() before read_posted_fields().
	 *
	 * @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/customer-picker/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 now searches the REST namespace through apiFetch, so the handle must
		// be present even if a stale asset file predates that.
		if ( ! in_array( 'wp-api-fetch', $dependencies, true ) ) {
			$dependencies[] = 'wp-api-fetch';
		}

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

		/*
		 * The wp-components SCRIPT handle arrives on its own in the generated asset
		 * file, 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, 'wrenchpressCustomerPicker', self::bootstrap() );
	}

	/**
	 * The window.wrenchpressCustomerPicker bootstrap.
	 *
	 * The selectedCustomer key IS THE HYDRATION MECHANISM, and it is why there is no "fetch
	 * the selected row" REST call anywhere in this feature (D-OTQ-05). On an edit form the
	 * server ALREADY KNOWS the saved owner's label: it resolved the id with a
	 * status-agnostic ::get(). Shipping it here means the picker renders the right owner
	 * before first paint, with no round trip, no flash of an empty field over a real id, and
	 * it keeps working even if the search endpoint is down.
	 *
	 * It also covers the case a search endpoint CANNOT cover: an ARCHIVED owner. Search
	 * deliberately excludes archived rows, because an archived customer must not be offered
	 * as a new choice. Without this pin, opening a vehicle whose owner was archived shows an
	 * empty picker, and saving reassigns or drops the owner.
	 *
	 * @return array
	 */
	private static function bootstrap() {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only prefill of an edit form; every write re-checks its own nonce and capability.
		$vehicle_id = isset( $_GET['vehicle'] ) ? absint( wp_unslash( $_GET['vehicle'] ) ) : 0;

		// After a validation error the form re-renders from the POSTed values, so those win.
		// Otherwise the saved owner of the vehicle being edited. Zero on a fresh add form.
		$selected_id = isset( self::$form_values['customer_id'] )
			? absint( self::$form_values['customer_id'] )
			: 0;

		if ( 0 === $selected_id && $vehicle_id > 0 ) {
			$vehicle = WrenchPress_Vehicles_Repository::get( $vehicle_id );

			if ( $vehicle ) {
				$selected_id = (int) $vehicle->customer_id;
			}
		}

		return array(
			'restBase'         => esc_url_raw( rest_url( 'wrenchpress/v1' ) ),
			'nonce'            => wp_create_nonce( 'wp_rest' ),
			'perPage'          => 20,
			'selectedCustomer' => WrenchPress_Picker_Options::selected_customer( $selected_id ),
			'i18n'             => array(
				'customer'       => __( 'Customer', 'pitdesk-repair-crm' ),
				'required'       => __( '(required)', 'pitdesk-repair-crm' ),

				/*
				 * THE SAME SENTENCE validate() RETURNS. The read copy and the heard
				 * copy must not diverge: a user who fails the client guard and a user
				 * who fails the server gate are told the identical thing.
				 */
				'chooseCustomer' => __( 'Choose the customer who owns this vehicle.', 'pitdesk-repair-crm' ),
				'searching'      => __( 'Searching customers...', 'pitdesk-repair-crm' ),
				'noResults'      => __( 'No customers match that search.', 'pitdesk-repair-crm' ),
				'searchError'    => __( 'The customer 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' ),
			),
		);
	}

	/**
	 * Merge submitted/loaded values over the empty-field defaults.
	 *
	 * @param array<string,string|int> $values Partial values.
	 * @return array<string,string|int>
	 */
	private static function form_values_with_defaults( array $values ) {
		return wp_parse_args(
			$values,
			array(
				'customer_id'   => 0,
				'make'          => '',
				'model'         => '',
				'year'          => 0,
				'vin'           => '',
				'plate'         => '',
				'mileage'       => 0,
				'fuel_type'     => '',
				'transmission'  => '',
				'color'         => '',
				'engine'        => '',
				'engine_number' => '',
			)
		);
	}

	/**
	 * URL of the Vehicles list screen.
	 *
	 * @return string
	 */
	private static function list_url() {
		return add_query_arg( array( 'page' => self::MENU_SLUG ), admin_url( 'admin.php' ) );
	}

	/**
	 * Format a stored datetime for display, or return an empty string.
	 *
	 * @param string $value Stored datetime.
	 * @return string
	 */
	private static function format_datetime( $value ) {
		$timestamp = strtotime( (string) $value );

		if ( ! $timestamp ) {
			return '';
		}

		return date_i18n( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $timestamp );
	}

	/**
	 * Render one text input row in the form table.
	 *
	 * @param string $name     Field name.
	 * @param string $label    Visible label.
	 * @param string $value    Current value.
	 * @param bool   $required Whether the field is required.
	 * @param string $type     Input type attribute.
	 * @param string $suffix   Optional trailing label shown after the input (e.g. a unit).
	 * @return void
	 */
	private static function text_row( $name, $label, $value, $required, $type = 'text', $suffix = '' ) {
		$id = 'wrenchpress_' . $name;

		echo '<tr><th scope="row"><label for="' . esc_attr( $id ) . '">' . esc_html( $label );

		if ( $required ) {
			echo ' <span class="description">' . esc_html__( '(required)', 'pitdesk-repair-crm' ) . '</span>';
		}

		echo '</label></th><td>';
		printf(
			'<input name="%1$s" id="%2$s" type="%3$s" value="%4$s" class="regular-text"%5$s />',
			esc_attr( $name ),
			esc_attr( $id ),
			esc_attr( $type ),
			esc_attr( $value ),
			$required ? ' required' : ''
		);

		if ( '' !== $suffix ) {
			echo ' <span class="description">' . esc_html( $suffix ) . '</span>';
		}

		echo '</td></tr>';
	}
}
