<?php
/**
 * Work Orders admin screen: page router and write handlers.
 *
 * Routes between the status-filtered list, the add form, and the read-only detail
 * "screen" that hosts the status control, audit + notes timeline, line items, and
 * totals. 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. Every write is nonce-verified, capability-gated,
 * sanitizes its input, and uses Post/Redirect/Get on success.
 *
 * Capability split (D-20): creating a work order and editing its priced line
 * items require MANAGE_WORKORDERS, which the mechanic does not hold; moving the
 * status and adding progress notes require the mechanic-held CHANGE_WORKORDER_
 * STATUS and ADD_WORKORDER_NOTES caps. A Closed work order is locked: every
 * mutating handler except reopen refuses to act on it (D-12).
 *
 * @package WrenchPress
 */

defined( 'ABSPATH' ) || exit;

/**
 * Class WrenchPress_Work_Orders_Admin_Page
 */
class WrenchPress_Work_Orders_Admin_Page {

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

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

	/**
	 * Nonce-action prefix for a per-work-order status change. The work-order id is
	 * appended so a nonce minted for one work order cannot move another.
	 */
	const STATUS_NONCE = 'wrenchpress_wo_status_';

	/**
	 * Nonce-action prefix for reopening a closed work order.
	 */
	const REOPEN_NONCE = 'wrenchpress_wo_reopen_';

	/**
	 * Nonce-action prefix for adding a progress note.
	 */
	const NOTE_NONCE = 'wrenchpress_wo_note_';

	/**
	 * Nonce-action prefix for setting a close-time service reminder. The work-order
	 * id is appended so a nonce minted for one work order cannot set a reminder for
	 * another (T-05-15).
	 */
	const SET_REMINDER_NONCE = 'wrenchpress_wo_set_reminder_';

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

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

	/**
	 * Dispatch write actions on page load (before output).
	 *
	 * Create is a POST submission. The status/note/line-item/reopen handlers are
	 * wired below and added in the later tasks of this slice.
	 *
	 * @return void
	 */
	public static function handle_load() {
		$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.

		switch ( $post_action ) {
			case 'create':
				self::handle_create();
				break;

			case 'status':
				self::handle_status_change();
				break;

			case 'reopen':
				self::handle_reopen();
				break;

			case 'note':
				self::handle_add_note();
				break;

			case 'set_reminder':
				self::handle_set_reminder();
				break;

			case 'add_item':
				self::handle_add_item();
				break;

			case 'update_item':
				self::handle_update_item();
				break;

			case 'delete_item':
				self::handle_delete_item();
				break;

			case 'update_tax_rate':
				self::handle_update_tax_rate();
				break;
		}
	}

	/**
	 * Validate and open a new work order (PRG to its detail on success).
	 *
	 * Requires MANAGE_WORKORDERS after the nonce check so a mechanic cannot create
	 * a work order (D-20). Both customer_id and vehicle_id are required and must
	 * resolve to real records (D-09). The new work order is pre-filled with the
	 * shop-wide default tax rate from Settings (D-15).
	 *
	 * @return void
	 */
	private static function handle_create() {
		check_admin_referer( self::CREATE_NONCE );

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

		$customer_id = isset( $_POST['customer_id'] ) ? absint( wp_unslash( $_POST['customer_id'] ) ) : 0;
		$vehicle_id  = isset( $_POST['vehicle_id'] ) ? absint( wp_unslash( $_POST['vehicle_id'] ) ) : 0;

		$errors = self::validate_create( $customer_id, $vehicle_id );

		if ( ! empty( $errors ) ) {
			self::$form_errors = $errors;
			self::$form_values = array(
				'customer_id' => $customer_id,
				'vehicle_id'  => $vehicle_id,
			);
			return;
		}

		$new_id = WrenchPress_Work_Orders_Repository::create(
			array(
				'customer_id' => $customer_id,
				'vehicle_id'  => $vehicle_id,
				'status'      => 'new',
				'tax_rate'    => (string) WrenchPress_Settings_Page::default_tax_rate(),
			)
		);

		if ( ! $new_id ) {
			self::$form_errors = array( __( 'The work order could not be created.', 'pitdesk-repair-crm' ) );
			self::$form_values = array(
				'customer_id' => $customer_id,
				'vehicle_id'  => $vehicle_id,
			);
			return;
		}

		self::redirect_to_detail( (int) $new_id, 'created' );
	}

	/**
	 * Validate the create form: both ids required and resolvable (D-09).
	 *
	 * @param int $customer_id Owning customer id.
	 * @param int $vehicle_id  Vehicle id.
	 * @return string[] Error messages (empty when valid).
	 */
	private static function validate_create( $customer_id, $vehicle_id ) {
		$errors = array();

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

		if ( 0 === $vehicle_id ) {
			$errors[] = __( 'Choose the vehicle this work order is for.', 'pitdesk-repair-crm' );
		} else {
			$vehicle = WrenchPress_Vehicles_Repository::get( $vehicle_id );

			if ( ! $vehicle ) {
				$errors[] = __( 'That vehicle could not be found.', 'pitdesk-repair-crm' );
			} elseif ( $customer_id > 0 && (int) $vehicle->customer_id !== (int) $customer_id ) {
				// Reject linking a customer to another customer's vehicle so the
				// service-history and customer-360 cross-links stay consistent.
				$errors[] = __( 'That vehicle does not belong to the selected customer.', 'pitdesk-repair-crm' );
			}
		}

		return $errors;
	}

	/**
	 * Redirect to a work order's detail screen with a status flag and exit (PRG).
	 *
	 * @param int    $id   Work-order id.
	 * @param string $flag Query flag for the notice.
	 * @return void
	 */
	private static function redirect_to_detail( $id, $flag ) {
		wp_safe_redirect(
			add_query_arg(
				array(
					'page'       => self::MENU_SLUG,
					'action'     => 'view',
					'work_order' => (int) $id,
					$flag        => 1,
				),
				admin_url( 'admin.php' )
			)
		);
		exit;
	}

	/**
	 * Move a work order to a new status and stamp an audit event (D-11/D-13).
	 *
	 * Requires the mechanic-held CHANGE_WORKORDER_STATUS cap (D-20). Movement is
	 * free any-to-any: the only constraint is that the target is a known status
	 * slug. A Closed work order is locked and cannot transition here (D-12); it
	 * must go through reopen first. Every successful move writes a from/to audit
	 * row through the events log.
	 *
	 * @return void
	 */
	private static function handle_status_change() {
		$id = isset( $_POST['work_order'] ) ? absint( wp_unslash( $_POST['work_order'] ) ) : 0;

		check_admin_referer( self::STATUS_NONCE . $id );

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

		$wo = $id > 0 ? WrenchPress_Work_Orders_Repository::get( $id ) : null;

		if ( ! $wo ) {
			self::redirect_to_detail( $id, 'error' );
		}

		// Closed is locked: refuse to transition a closed work order (D-12).
		if ( 'closed' === (string) $wo->status ) {
			self::redirect_to_detail( $id, 'locked' );
		}

		$to = isset( $_POST['status'] ) ? sanitize_key( wp_unslash( $_POST['status'] ) ) : '';

		if ( ! array_key_exists( $to, WrenchPress_Work_Orders_Repository::statuses() ) ) {
			self::redirect_to_detail( $id, 'error' );
		}

		WrenchPress_Work_Orders_Repository::transition_status( $id, $to );
		WrenchPress_Events_Repository::log_status_change( $id, (string) $wo->status, $to );

		self::redirect_to_detail( $id, 'status_changed' );
	}

	/**
	 * Reopen a closed work order (D-12). The only status handler exempt from the
	 * closed lock.
	 *
	 * Reopening unlocks pricing again, so it is a manage action gated on
	 * MANAGE_WORKORDERS, not a mechanic action. The work order moves back to the
	 * in-progress status and the move is recorded in the audit log.
	 *
	 * @return void
	 */
	private static function handle_reopen() {
		$id = isset( $_POST['work_order'] ) ? absint( wp_unslash( $_POST['work_order'] ) ) : 0;

		check_admin_referer( self::REOPEN_NONCE . $id );

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

		$wo = $id > 0 ? WrenchPress_Work_Orders_Repository::get( $id ) : null;

		if ( ! $wo ) {
			self::redirect_to_detail( $id, 'error' );
		}

		// Only a closed work order can be reopened; anything else is a no-op.
		if ( 'closed' !== (string) $wo->status ) {
			self::redirect_to_detail( $id, 'error' );
		}

		$to = 'in_progress';

		WrenchPress_Work_Orders_Repository::transition_status( $id, $to );
		WrenchPress_Events_Repository::log_status_change( $id, (string) $wo->status, $to );

		self::redirect_to_detail( $id, 'reopened' );
	}

	/**
	 * Append a progress note to a work order (D-21).
	 *
	 * Requires the mechanic-held ADD_WORKORDER_NOTES cap (D-20). Notes are an
	 * append-only log: there is no edit or delete path, and a note may be added
	 * even to a closed work order because a closing remark does not mutate priced
	 * data. An empty note is rejected.
	 *
	 * @return void
	 */
	private static function handle_add_note() {
		$id = isset( $_POST['work_order'] ) ? absint( wp_unslash( $_POST['work_order'] ) ) : 0;

		check_admin_referer( self::NOTE_NONCE . $id );

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

		$wo = $id > 0 ? WrenchPress_Work_Orders_Repository::get( $id ) : null;

		if ( ! $wo ) {
			self::redirect_to_detail( $id, 'error' );
		}

		$note = isset( $_POST['note'] ) ? sanitize_textarea_field( wp_unslash( $_POST['note'] ) ) : '';

		if ( '' === trim( $note ) ) {
			self::redirect_to_detail( $id, 'note_empty' );
		}

		WrenchPress_Events_Repository::add_note( $id, $note );

		self::redirect_to_detail( $id, 'note_added' );
	}

	/**
	 * Set a close-time service reminder for the work order's vehicle (REMD-01).
	 *
	 * Requires MANAGE_REMINDERS after the per-record nonce check (T-05-15/T-05-18).
	 * The work order is loaded with the plain get() reader, NEVER load_for_pricing()
	 * (which redirects a closed work order with a 'locked' flag): the reminder is a
	 * write to a DIFFERENT table and must succeed on a closed work order, so it
	 * bypasses the WO Closed-lock safely (T-05-16). customer_id and vehicle_id are
	 * derived from the loaded work order, never from POST.
	 *
	 * The due date is accepted only as a strict Y-m-d (else treated as empty) and
	 * the mileage is read in the active DISPLAY unit and converted once to canonical
	 * miles at this boundary (T-05-17). At least one of the two due values must be
	 * present; otherwise nothing is written and the request redirects with
	 * reminder_empty.
	 *
	 * @return void
	 */
	private static function handle_set_reminder() {
		$id = isset( $_POST['work_order'] ) ? absint( wp_unslash( $_POST['work_order'] ) ) : 0;

		check_admin_referer( self::SET_REMINDER_NONCE . $id );

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

		// Load with the plain reader, not load_for_pricing(): the reminder writes to
		// the reminders table and must succeed even though the work order is closed.
		$wo = $id > 0 ? WrenchPress_Work_Orders_Repository::get( $id ) : null;

		if ( ! $wo ) {
			self::redirect_to_detail( $id, 'error' );
		}

		$raw_date = isset( $_POST['due_date'] ) ? sanitize_text_field( wp_unslash( $_POST['due_date'] ) ) : '';
		$due_date = '';

		if ( '' !== $raw_date ) {
			$parsed = DateTimeImmutable::createFromFormat( 'Y-m-d', $raw_date );

			if ( $parsed instanceof DateTimeImmutable && $parsed->format( 'Y-m-d' ) === $raw_date ) {
				$due_date = $raw_date;
			}
		}

		$display_mileage   = isset( $_POST['due_mileage'] ) ? absint( wp_unslash( $_POST['due_mileage'] ) ) : 0;
		$canonical_mileage = WrenchPress_Reminders_Repository::mileage_to_canonical(
			$display_mileage,
			WrenchPress_Settings_Page::mileage_unit()
		);

		if ( '' === $due_date && $canonical_mileage <= 0 ) {
			self::redirect_to_detail( $id, 'reminder_empty' );
		}

		$created = WrenchPress_Reminders_Repository::create_superseding(
			array(
				'customer_id'   => (int) $wo->customer_id,
				'vehicle_id'    => (int) $wo->vehicle_id,
				'work_order_id' => $id,
				'due_date'      => $due_date,
				'due_mileage'   => $canonical_mileage,
			)
		);

		if ( false === $created ) {
			self::redirect_to_detail( $id, 'reminder_error' );
		}

		self::redirect_to_detail( $id, 'reminder_set' );
	}

	/**
	 * Load and guard a work order for a line-item / pricing mutation.
	 *
	 * Centralizes the shared gate for every priced write (D-12/D-20): the work
	 * order must exist, the caller must hold MANAGE_WORKORDERS (the mechanic does
	 * not), and the work order must not be closed. Any failure ends the request
	 * via PRG or wp_die; on success the loaded row is returned. The caller is
	 * responsible for verifying the nonce before calling this.
	 *
	 * @param int $id Work-order id.
	 * @return object Work-order row (the method does not return on failure).
	 */
	private static function load_for_pricing( $id ) {
		if ( ! current_user_can( WrenchPress_Capabilities::MANAGE_WORKORDERS ) ) {
			wp_die( esc_html__( 'You do not have permission to do that.', 'pitdesk-repair-crm' ) );
		}

		$wo = $id > 0 ? WrenchPress_Work_Orders_Repository::get( $id ) : null;

		if ( ! $wo ) {
			self::redirect_to_detail( $id, 'error' );
		}

		// Defense in depth: a crafted POST must not mutate a closed work order
		// even though the UI hides the controls (D-12 / Pitfall 4).
		if ( 'closed' === (string) $wo->status ) {
			self::redirect_to_detail( $id, 'locked' );
		}

		return $wo;
	}

	/**
	 * Largest value the quantity / unit_price DECIMAL(10,2) columns can hold.
	 *
	 * Anything above this would be silently truncated or rejected by MySQL, so
	 * the sanitizer clamps to it rather than letting an out-of-range value reach
	 * the column (class-schema.php DECIMAL(10,2)).
	 */
	const MONEY_MAX = 99999999.99;

	/**
	 * Validate a posted money field (quantity or unit price), distinguishing
	 * invalid input from a legitimate zero.
	 *
	 * Only plain decimal input is accepted: scientific notation (e.g. "1e9") and
	 * any other non-decimal form return null so callers can reject the request
	 * instead of silently inserting a zero line. A legitimate "0" still returns
	 * "0.00", and the value is clamped to the DECIMAL(10,2) column's
	 * representable range and normalized to a fixed two-decimal string, which
	 * also avoids float-formatting surprises downstream.
	 *
	 * @param string $key POST key.
	 * @return string|null Non-negative two-decimal string, or null when invalid.
	 */
	private static function validate_amount( $key ) {
		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- callers verify the nonce before reading these fields.
		$raw = isset( $_POST[ $key ] ) ? sanitize_text_field( wp_unslash( $_POST[ $key ] ) ) : '0';
		$raw = trim( $raw );

		if ( '' === $raw ) {
			$raw = '0';
		}

		// Accept only a plain optionally-signed decimal; reject scientific
		// notation and anything else by returning the invalid sentinel.
		if ( ! preg_match( '/^-?\d+(\.\d+)?$/', $raw ) ) {
			return null;
		}

		$value = max( 0.0, min( self::MONEY_MAX, (float) $raw ) );

		return number_format( $value, 2, '.', '' );
	}

	/**
	 * Add a labor or parts line item (WORK-03/D-14).
	 *
	 * @return void
	 */
	private static function handle_add_item() {
		$id = isset( $_POST['work_order'] ) ? absint( wp_unslash( $_POST['work_order'] ) ) : 0;

		check_admin_referer( self::CREATE_NONCE . $id );

		self::load_for_pricing( $id );

		$item_type = isset( $_POST['item_type'] ) ? sanitize_key( wp_unslash( $_POST['item_type'] ) ) : 'labor';

		$quantity   = self::validate_amount( 'quantity' );
		$unit_price = self::validate_amount( 'unit_price' );

		if ( null === $quantity || null === $unit_price ) {
			self::redirect_to_detail( $id, 'item_invalid' );
		}

		WrenchPress_Work_Order_Items_Repository::create(
			array(
				'work_order_id' => $id,
				'item_type'     => $item_type,
				'description'   => isset( $_POST['description'] ) ? sanitize_textarea_field( wp_unslash( $_POST['description'] ) ) : '',
				'quantity'      => $quantity,
				'unit_price'    => $unit_price,
				'position'      => count( WrenchPress_Work_Order_Items_Repository::list_for_work_order( $id ) ),
			)
		);

		self::redirect_to_detail( $id, 'item_added' );
	}

	/**
	 * Update an existing line item (WORK-03/D-14).
	 *
	 * The item is keyed on its id and re-checked to belong to this work order so a
	 * crafted id cannot edit another work order's row (T-03-19).
	 *
	 * @return void
	 */
	private static function handle_update_item() {
		$id      = isset( $_POST['work_order'] ) ? absint( wp_unslash( $_POST['work_order'] ) ) : 0;
		$item_id = isset( $_POST['item'] ) ? absint( wp_unslash( $_POST['item'] ) ) : 0;

		check_admin_referer( self::CREATE_NONCE . $id );

		self::load_for_pricing( $id );

		if ( ! self::item_belongs_to( $item_id, $id ) ) {
			self::redirect_to_detail( $id, 'error' );
		}

		$item_type = isset( $_POST['item_type'] ) ? sanitize_key( wp_unslash( $_POST['item_type'] ) ) : 'labor';

		$quantity   = self::validate_amount( 'quantity' );
		$unit_price = self::validate_amount( 'unit_price' );

		if ( null === $quantity || null === $unit_price ) {
			self::redirect_to_detail( $id, 'item_invalid' );
		}

		WrenchPress_Work_Order_Items_Repository::update(
			$item_id,
			array(
				'item_type'   => $item_type,
				'description' => isset( $_POST['description'] ) ? sanitize_textarea_field( wp_unslash( $_POST['description'] ) ) : '',
				'quantity'    => $quantity,
				'unit_price'  => $unit_price,
			)
		);

		self::redirect_to_detail( $id, 'item_updated' );
	}

	/**
	 * Delete a line item (WORK-03/D-14).
	 *
	 * @return void
	 */
	private static function handle_delete_item() {
		$id      = isset( $_POST['work_order'] ) ? absint( wp_unslash( $_POST['work_order'] ) ) : 0;
		$item_id = isset( $_POST['item'] ) ? absint( wp_unslash( $_POST['item'] ) ) : 0;

		check_admin_referer( self::CREATE_NONCE . $id );

		self::load_for_pricing( $id );

		if ( ! self::item_belongs_to( $item_id, $id ) ) {
			self::redirect_to_detail( $id, 'error' );
		}

		WrenchPress_Work_Order_Items_Repository::delete( $item_id );

		self::redirect_to_detail( $id, 'item_deleted' );
	}

	/**
	 * Override a work order's tax rate (D-15).
	 *
	 * Entered as a percentage (0 to 100), clamped, and stored as a decimal
	 * fraction so it can be zeroed or set per work order independently of the shop
	 * default it was seeded from.
	 *
	 * @return void
	 */
	private static function handle_update_tax_rate() {
		$id = isset( $_POST['work_order'] ) ? absint( wp_unslash( $_POST['work_order'] ) ) : 0;

		check_admin_referer( self::CREATE_NONCE . $id );

		self::load_for_pricing( $id );

		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified above.
		$raw     = isset( $_POST['tax_rate'] ) ? sanitize_text_field( wp_unslash( $_POST['tax_rate'] ) ) : '0';
		$percent = is_numeric( $raw ) ? (float) $raw : 0.0;
		$percent = max( 0.0, min( 100.0, $percent ) );

		WrenchPress_Work_Orders_Repository::update(
			$id,
			array( 'tax_rate' => (string) round( $percent / 100, 4 ) )
		);

		self::redirect_to_detail( $id, 'tax_updated' );
	}

	/**
	 * Whether a line item exists and belongs to the given work order.
	 *
	 * @param int $item_id       Line-item id.
	 * @param int $work_order_id Work-order id.
	 * @return bool
	 */
	private static function item_belongs_to( $item_id, $work_order_id ) {
		$item_id       = (int) $item_id;
		$work_order_id = (int) $work_order_id;

		if ( 0 === $item_id || 0 === $work_order_id ) {
			return false;
		}

		foreach ( WrenchPress_Work_Order_Items_Repository::list_for_work_order( $work_order_id ) as $item ) {
			if ( (int) $item->id === $item_id ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Route the page to the list, add form, or read-only detail 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 ( 'add' === $action || ! empty( self::$form_errors ) ) {
			self::render_add_form();
			return;
		}

		self::render_list();
	}

	/**
	 * Render the status-filtered Work Orders list with the status-tab views.
	 *
	 * The list table's views() must be printed explicitly (it is not emitted by
	 * display()); a hidden page and status input keep the active filter across the
	 * search form.
	 *
	 * @return void
	 */
	private static function render_list() {
		$list_table = new WrenchPress_Work_Orders_List_Table();
		$list_table->prepare_items();

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

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter carried across the search form.
		$status = isset( $_GET['status'] ) ? sanitize_key( wp_unslash( $_GET['status'] ) ) : '';

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

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

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

		self::maybe_render_list_notice();

		// Status tabs with counts (D-18). Not auto-printed by display().
		$list_table->views();

		echo '<form method="get">';
		echo '<input type="hidden" name="page" value="' . esc_attr( self::MENU_SLUG ) . '" />';

		if ( '' !== $status ) {
			echo '<input type="hidden" name="status" value="' . esc_attr( $status ) . '" />';
		}

		$list_table->display();
		echo '</form>';

		echo '</div>';
	}

	/**
	 * Render the success notice for the list-level PRG flags.
	 *
	 * @return void
	 */
	private static function maybe_render_list_notice() {
		// phpcs:disable WordPress.Security.NonceVerification.Recommended -- status flags come from our own PRG redirects.
		if ( ! isset( $_GET['created'] ) ) {
			return;
		}
		// phpcs:enable WordPress.Security.NonceVerification.Recommended

		echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__( 'Work order created.', 'pitdesk-repair-crm' ) . '</p></div>';
	}

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

		$values   = wp_parse_args(
			self::$form_values,
			array(
				'customer_id' => 0,
				'vehicle_id'  => 0,
			)
		);
		$list_url = self::list_url();

		/*
		 * THE TWO 'per_page' => 500 FETCHES ARE GONE. They were the last of the five picker
		 * caps: past 500 customers, this form could not open a work order for customer 501,
		 * and nothing on the screen said why.
		 *
		 * These are now the BOUNDED NO-JAVASCRIPT lists, with the selected row injected (see
		 * WrenchPress_Picker_Options). With the island running the pickers search the whole
		 * database and there is no cap at all.
		 */
		$selected_customer = (int) $values['customer_id'];
		$selected_vehicle  = (int) $values['vehicle_id'];

		$customers = WrenchPress_Picker_Options::customer_options( $selected_customer );
		$vehicles  = WrenchPress_Picker_Options::vehicle_options( $selected_vehicle );

		echo '<div class="wrap">';
		echo '<h1 class="wp-heading-inline">' . esc_html__( 'Add Work Order', 'pitdesk-repair-crm' ) . '</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( self::CREATE_NONCE );
		echo '<input type="hidden" name="wrenchpress_action" value="create" />';

		echo '<table class="form-table" role="presentation"><tbody>';

		/*
		 * THE PLACEHOLDERS ARE value="", AND value="0" IS NOT A HARMLESS SPELLING OF IT.
		 *
		 * A <select required> is invalid ONLY when the selected option's value is the EMPTY
		 * STRING. With value="0" the placeholder is a NON-EMPTY value, the constraint is
		 * satisfied the moment the page loads, and `required` NEVER FIRES. Measured on the
		 * vehicle form (np3): validity.valid came back TRUE with the placeholder selected.
		 * The attribute was decoration on BOTH of these selects, and the no-JS path never
		 * blocked an empty submit at all; it round-tripped to the server, which caught it in
		 * validate_create() and re-rendered.
		 *
		 * "" makes the native constraint real, which is what the progressive enhancement
		 * rests on. The POST contract is byte-identical, because handle_create() runs
		 * absint() and absint( '' ) === absint( '0' ) === 0, which validate_create() already
		 * rejects.
		 *
		 * `required` is emitted here and is NOT negotiable: with JavaScript off these are
		 * visible, focusable controls and the browser natively blocks and reports. The island
		 * STRIPS the attribute when it takes over, because a required control the browser
		 * cannot focus blocks submit and then cannot report the failure.
		 */

		// Customer picker (required).
		echo '<tr><th scope="row"><label for="wrenchpress_wo_customer">' . esc_html__( 'Customer', 'pitdesk-repair-crm' );
		echo ' <span class="description">' . esc_html__( '(required)', 'pitdesk-repair-crm' ) . '</span></label></th><td>';
		echo '<div class="wrenchpress-wo-picker-mount" data-picker="customer"></div>';
		echo '<select name="customer_id" id="wrenchpress_wo_customer" data-wrenchpress-picker="customer" required>';
		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_customer, (int) $customer['value'], false ) . '>' . esc_html( $customer['label'] ) . '</option>';
		}
		echo '</select>';

		$customer_notice = WrenchPress_Picker_Options::cap_notice(
			count( $customers ),
			WrenchPress_Picker_Options::customer_total(),
			add_query_arg( array( 'page' => 'wrenchpress-customers' ), 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 $customer_notice;
		echo '</td></tr>';

		// Vehicle picker (required).
		echo '<tr><th scope="row"><label for="wrenchpress_wo_vehicle">' . esc_html__( 'Vehicle', 'pitdesk-repair-crm' );
		echo ' <span class="description">' . esc_html__( '(required)', 'pitdesk-repair-crm' ) . '</span></label></th><td>';
		echo '<div class="wrenchpress-wo-picker-mount" data-picker="vehicle"></div>';
		echo '<select name="vehicle_id" id="wrenchpress_wo_vehicle" data-wrenchpress-picker="vehicle" required>';
		echo '<option value="">' . esc_html__( '- Select a vehicle -', 'pitdesk-repair-crm' ) . '</option>';
		foreach ( $vehicles as $vehicle ) {
			echo '<option value="' . esc_attr( (string) (int) $vehicle['value'] ) . '"' . selected( $selected_vehicle, (int) $vehicle['value'], false ) . '>' . esc_html( $vehicle['label'] ) . '</option>';
		}
		echo '</select>';

		/*
		 * THE OLD COPY WAS FALSE, AND THE RETIREMENT OF work-order-form.js MADE IT FALSER.
		 *
		 * It read "Pick the customer first; the vehicle list narrows to that customer." With
		 * JavaScript off that was never true: every vehicle stayed selectable, and the only
		 * thing standing between a user and a mismatched pairing was the server. This copy is
		 * true in BOTH modes, and it says which one is doing the work.
		 */
		echo '<p class="description">' . esc_html__( 'The vehicle must belong to the chosen customer. With JavaScript available the list narrows to that customer as soon as you pick one, and typing searches every vehicle in the shop by name, VIN, plate or engine number. The pairing is checked again when the work order is saved.', 'pitdesk-repair-crm' ) . '</p>';

		$vehicle_notice = WrenchPress_Picker_Options::cap_notice(
			count( $vehicles ),
			WrenchPress_Picker_Options::vehicle_total(),
			add_query_arg( array( 'page' => 'wrenchpress-vehicles' ), admin_url( 'admin.php' ) ),
			'vehicles'
		);

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

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

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

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

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

	/**
	 * Enqueue the built work-order-picker island on the Work Orders screen only.
	 *
	 * Hooked on admin_enqueue_scripts and gated on the hook suffix, exactly like the other
	 * two island hosts. The generated *.asset.php is file_exists()-guarded, so a missing
	 * build degrades to the plain server-rendered selects (which still work, still block an
	 * empty submit natively, and still disclose their bound) 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`, and an action gate would strip the
	 * enhancement from exactly the screen the user has just failed on. The script no-ops when
	 * no [data-wrenchpress-picker] element is present (the list and detail screens).
	 *
	 * No new nonce and no new capability surface: it reads and writes nothing. The render is
	 * already gated on MANAGE_WORKORDERS and handle_create() still runs check_admin_referer()
	 * and current_user_can() before it reads a single field.
	 *
	 * @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/work-order-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();

		if ( ! in_array( 'wp-api-fetch', $dependencies, true ) ) {
			$dependencies[] = 'wp-api-fetch';
		}

		wp_enqueue_script(
			self::SCRIPT_HANDLE,
			WRENCHPRESS_URL . 'build/work-order-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, 'wrenchpressWorkOrderPicker', self::picker_bootstrap() );
	}

	/**
	 * The window.wrenchpressWorkOrderPicker bootstrap.
	 *
	 * The selectedCustomer and selectedVehicle keys are the HYDRATION mechanism (D-OTQ-05),
	 * and they are not decorative. This form re-renders with its POSTed values after an error,
	 * and the pickers' options come from a SEARCH endpoint whose first page will not in
	 * general contain the rows the user had chosen. Core renders the collapsed field's text
	 * from the label of the selected option IN THE OPTIONS ARRAY, so without these both
	 * fields would come back EMPTY on top of correct ids: the user fixes one error and
	 * silently loses the other two answers.
	 *
	 * Resolved server-side through ::get(), which is status-agnostic, so an archived customer
	 * is named rather than blanked.
	 *
	 * @return array
	 */
	private static function picker_bootstrap() {
		$values = wp_parse_args(
			self::$form_values,
			array(
				'customer_id' => 0,
				'vehicle_id'  => 0,
			)
		);

		return array(
			'restBase'         => esc_url_raw( rest_url( 'wrenchpress/v1' ) ),
			'nonce'            => wp_create_nonce( 'wp_rest' ),
			'perPage'          => 20,
			'selectedCustomer' => WrenchPress_Picker_Options::selected_customer( (int) $values['customer_id'] ),
			'selectedVehicle'  => WrenchPress_Picker_Options::selected_vehicle( (int) $values['vehicle_id'] ),
			'i18n'             => array(
				'customer'       => __( 'Customer', 'pitdesk-repair-crm' ),
				'vehicle'        => __( 'Vehicle', 'pitdesk-repair-crm' ),

				/*
				 * THE SAME TWO SENTENCES validate_create() RETURNS. The read copy and the
				 * heard copy must not diverge: a user who trips the client guard and a user
				 * who trips the server gate are told the identical thing.
				 */
				'chooseCustomer' => __( 'Choose the customer this work order is for.', 'pitdesk-repair-crm' ),
				'chooseVehicle'  => __( 'Choose the vehicle this work order is for.', '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' ),
			),
		);
	}

	/**
	 * Render the read-only detail "screen" for a single work order.
	 *
	 * The header (work-order number, customer, vehicle, status badge) renders
	 * here; the status control, audit + notes timeline, line items, and totals are
	 * added by the later tasks of this slice.
	 *
	 * @return void
	 */
	private static function render_view() {
		$id = isset( $_GET['work_order'] ) ? absint( wp_unslash( $_GET['work_order'] ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only view.
		$wo = $id > 0 ? WrenchPress_Work_Orders_Repository::get( $id ) : null;

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

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

		self::maybe_render_detail_notice();

		$customer = WrenchPress_Customers_Repository::get( (int) $wo->customer_id );
		$vehicle  = WrenchPress_Vehicles_Repository::get( (int) $wo->vehicle_id );
		$statuses = WrenchPress_Work_Orders_Repository::statuses();
		$slug     = (string) $wo->status;
		$label    = isset( $statuses[ $slug ] ) ? $statuses[ $slug ] : $slug;

		$customer_name = $customer ? trim( sprintf( '%s, %s', $customer->last_name, $customer->first_name ), ', ' ) : '';
		$vehicle_label = $vehicle ? WrenchPress_Vehicles_List_Table::vehicle_label( $vehicle ) : '';

		echo '<h2>' . esc_html( '#' . (int) $wo->id ) . ' ';
		echo '<span class="wrenchpress-status wrenchpress-status--' . esc_attr( $slug ) . '">' . esc_html( $label ) . '</span></h2>';

		echo '<table class="form-table" role="presentation"><tbody>';
		echo '<tr><th scope="row">' . esc_html__( 'Customer', 'pitdesk-repair-crm' ) . '</th><td>' . esc_html( $customer_name ) . '</td></tr>';
		echo '<tr><th scope="row">' . esc_html__( 'Vehicle', 'pitdesk-repair-crm' ) . '</th><td>' . esc_html( $vehicle_label ) . '</td></tr>';
		echo '<tr><th scope="row">' . esc_html__( 'Opened', 'pitdesk-repair-crm' ) . '</th><td>' . esc_html( self::format_datetime( $wo->created_at ) ) . '</td></tr>';
		echo '</tbody></table>';

		self::render_status_control( $wo );
		self::render_reminder_prompt( $wo );
		self::render_line_items( $wo );
		WrenchPress_Documents_Admin::render_generate_section( $wo );
		self::render_timeline( (int) $wo->id );
		self::render_note_form( $wo );

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

	/**
	 * Render the status control: a move-status form when open, or the reopen
	 * confirmation when closed (D-12).
	 *
	 * The move form is shown only to a user who holds CHANGE_WORKORDER_STATUS and
	 * only while the work order is not closed; a closed work order instead shows a
	 * single reopen confirmation, gated on MANAGE_WORKORDERS, so a mechanic never
	 * sees the reopen control (D-20).
	 *
	 * @param object $wo Work-order row.
	 * @return void
	 */
	private static function render_status_control( $wo ) {
		$id        = (int) $wo->id;
		$is_closed = 'closed' === (string) $wo->status;
		$action    = self::list_url();

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

		if ( ! $is_closed ) {
			if ( ! current_user_can( WrenchPress_Capabilities::CHANGE_WORKORDER_STATUS ) ) {
				return;
			}

			echo '<form method="post" action="' . esc_url( $action ) . '">';
			wp_nonce_field( self::STATUS_NONCE . $id );
			echo '<input type="hidden" name="wrenchpress_action" value="status" />';
			echo '<input type="hidden" name="work_order" value="' . esc_attr( (string) $id ) . '" />';
			echo '<label class="screen-reader-text" for="wrenchpress_wo_status_select">' . esc_html__( 'Status', 'pitdesk-repair-crm' ) . '</label>';
			echo '<select name="status" id="wrenchpress_wo_status_select">';
			foreach ( WrenchPress_Work_Orders_Repository::statuses() as $slug => $label ) {
				echo '<option value="' . esc_attr( $slug ) . '"' . selected( (string) $wo->status, $slug, false ) . '>' . esc_html( $label ) . '</option>';
			}
			echo '</select> ';
			echo '<button type="submit" class="button">' . esc_html__( 'Update status', 'pitdesk-repair-crm' ) . '</button>';
			echo '</form>';
			return;
		}

		// Closed: the work order is locked. Only a manage user sees reopen (D-20).
		echo '<p>' . esc_html__( 'This work order is closed and locked. Line items and pricing cannot be changed until it is reopened.', 'pitdesk-repair-crm' ) . '</p>';

		if ( ! current_user_can( WrenchPress_Capabilities::MANAGE_WORKORDERS ) ) {
			return;
		}

		echo '<form method="post" action="' . esc_url( $action ) . '">';
		wp_nonce_field( self::REOPEN_NONCE . $id );
		echo '<input type="hidden" name="wrenchpress_action" value="reopen" />';
		echo '<input type="hidden" name="work_order" value="' . esc_attr( (string) $id ) . '" />';

		/*
		 * The button stays a real submit button inside a real form, and the
		 * nonce above stays the only thing protecting the reopen. The data
		 * attributes are read by assets/js/wrenchpress-dialog.js, which
		 * intercepts the click and asks for confirmation in a native <dialog>.
		 * If that script never loads, the button simply submits, which is the
		 * correct degradation: the confirmation is a courtesy, not the gate.
		 */
		echo '<button type="submit" class="button"';
		echo ' data-wrenchpress-confirm="' . esc_attr__( 'This work order is closed. Reopening it allows items and pricing to be edited again.', 'pitdesk-repair-crm' ) . '"';
		echo ' data-wrenchpress-confirm-title="' . esc_attr__( 'Reopen work order?', 'pitdesk-repair-crm' ) . '"';
		echo ' data-wrenchpress-confirm-cancel="' . esc_attr__( 'Keep it closed', 'pitdesk-repair-crm' ) . '"';
		echo ' data-wrenchpress-confirm-ok="' . esc_attr__( 'Reopen work order', 'pitdesk-repair-crm' ) . '"';
		echo '>' . esc_html__( 'Reopen this work order', 'pitdesk-repair-crm' ) . '</button>';
		echo '</form>';
	}

	/**
	 * Render the non-blocking close-time Service reminder section (REMD-01).
	 *
	 * Only appears on a CLOSED work order and only to a user who holds
	 * MANAGE_REMINDERS, so a mechanic who closed the work order never sees it and
	 * the close stays silent for them (UI-SPEC Surface 2). The section never gates
	 * the close: it writes to the reminders table (exempt from the WO Closed-lock),
	 * and skipping writes nothing.
	 *
	 * Two states: when the vehicle already has a pending reminder the section is
	 * read-only with a link to the worklist; otherwise it shows a prefilled form
	 * (due date = today + default months; due mileage = vehicle mileage + default
	 * distance, in the active display unit) that supersede-writes a new pending
	 * reminder when confirmed.
	 *
	 * @param object $wo Work-order row.
	 * @return void
	 */
	private static function render_reminder_prompt( $wo ) {
		if ( ! current_user_can( WrenchPress_Capabilities::MANAGE_REMINDERS ) ) {
			return;
		}

		if ( 'closed' !== (string) $wo->status ) {
			return;
		}

		$vehicle_id = (int) $wo->vehicle_id;
		$pending    = WrenchPress_Reminders_Repository::get_pending_for_vehicle( $vehicle_id );

		echo '<h2>' . esc_html__( 'Service reminder', 'pitdesk-repair-crm' ) . '</h2>';
		echo '<div class="notice notice-info inline wrenchpress-reminder-prompt">';

		if ( $pending ) {
			$worklist_url = add_query_arg(
				array( 'page' => WrenchPress_Reminders_Admin_Page::MENU_SLUG ),
				admin_url( 'admin.php' )
			);

			echo '<p>' . esc_html__( 'This vehicle already has an active reminder.', 'pitdesk-repair-crm' ) . ' ';
			echo esc_html( self::format_reminder_due( $pending ) ) . '</p>';
			echo '<p><a href="' . esc_url( $worklist_url ) . '">' . esc_html__( 'View worklist', 'pitdesk-repair-crm' ) . '</a></p>';
			echo '</div>';
			return;
		}

		$id             = (int) $wo->id;
		$vehicle        = WrenchPress_Vehicles_Repository::get( $vehicle_id );
		$unit           = WrenchPress_Settings_Page::mileage_unit();
		$unit_label     = 'km' === $unit ? __( 'km', 'pitdesk-repair-crm' ) : __( 'mi', 'pitdesk-repair-crm' );
		$default_months = WrenchPress_Settings_Page::reminder_default_months();
		$base           = new DateTimeImmutable( 'now', wp_timezone() );
		$due_date       = $base->modify( '+' . $default_months . ' months' )->format( 'Y-m-d' );

		$vehicle_mileage = $vehicle ? (int) $vehicle->mileage : 0;
		$due_mileage     = WrenchPress_Reminders_Repository::mileage_to_display( $vehicle_mileage, $unit )
			+ WrenchPress_Settings_Page::reminder_default_distance();

		echo '<form method="post" action="' . esc_url( self::list_url() ) . '">';
		wp_nonce_field( self::SET_REMINDER_NONCE . $id );
		echo '<input type="hidden" name="wrenchpress_action" value="set_reminder" />';
		echo '<input type="hidden" name="work_order" value="' . esc_attr( (string) $id ) . '" />';

		echo '<p class="description">' . esc_html__( 'Set a next-service reminder for this vehicle. Prefilled from your shop default - confirm, edit, or skip.', 'pitdesk-repair-crm' ) . '</p>';

		echo '<table class="form-table" role="presentation"><tbody>';

		echo '<tr><th scope="row"><label for="wrenchpress_reminder_due_date">' . esc_html__( 'Due date', 'pitdesk-repair-crm' ) . '</label></th><td>';
		echo '<input type="date" name="due_date" id="wrenchpress_reminder_due_date" value="' . esc_attr( (string) $due_date ) . '" /></td></tr>';

		echo '<tr><th scope="row"><label for="wrenchpress_reminder_due_mileage">' . esc_html__( 'Due mileage', 'pitdesk-repair-crm' ) . '</label></th><td>';
		echo '<input type="number" min="0" step="1" class="small-text" name="due_mileage" id="wrenchpress_reminder_due_mileage" value="' . esc_attr( (string) $due_mileage ) . '" /> ';
		echo esc_html( $unit_label ) . '</td></tr>';

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

		echo '<p class="submit">';
		echo '<button type="submit" class="button button-primary">' . esc_html__( 'Set reminder', 'pitdesk-repair-crm' ) . '</button> ';
		echo '<a href="' . esc_url( self::list_url() ) . '" class="button button-link">' . esc_html__( 'Skip for now', 'pitdesk-repair-crm' ) . '</a>';
		echo '</p>';

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

	/**
	 * Format a pending reminder's due summary for the read-only close-time note.
	 *
	 * Mirrors the worklist Due column: whichever of due date / due mileage is set,
	 * date first, joined with "or" when both are present. Sentinel values
	 * ('0000-00-00' date, 0 mileage) are never rendered. Canonical mileage is
	 * converted to the active display unit at this boundary only. The returned
	 * string is plain text (no markup) and is escaped by the caller.
	 *
	 * @param object $reminder Reminder row.
	 * @return string
	 */
	private static function format_reminder_due( $reminder ) {
		$parts = array();

		$due_date = (string) $reminder->due_date;
		if ( '' !== $due_date && '0000-00-00' !== $due_date ) {
			$timestamp = strtotime( $due_date );

			if ( $timestamp ) {
				$parts['date'] = date_i18n( get_option( 'date_format' ), $timestamp );
			}
		}

		$due_mileage = (int) $reminder->due_mileage;
		if ( $due_mileage > 0 ) {
			$unit             = WrenchPress_Settings_Page::mileage_unit();
			$unit_label       = 'km' === $unit ? __( 'km', 'pitdesk-repair-crm' ) : __( 'mi', 'pitdesk-repair-crm' );
			$display          = WrenchPress_Reminders_Repository::mileage_to_display( $due_mileage, $unit );
			$parts['mileage'] = number_format_i18n( $display ) . ' ' . $unit_label;
		}

		if ( isset( $parts['date'], $parts['mileage'] ) ) {
			return sprintf(
				/* translators: 1: due date, 2: due mileage with unit. */
				__( 'Due %1$s or %2$s.', 'pitdesk-repair-crm' ),
				$parts['date'],
				$parts['mileage']
			);
		}

		if ( isset( $parts['date'] ) ) {
			return sprintf(
				/* translators: %s: due date. */
				__( 'Due %s.', 'pitdesk-repair-crm' ),
				$parts['date']
			);
		}

		if ( isset( $parts['mileage'] ) ) {
			return sprintf(
				/* translators: %s: due mileage with unit. */
				__( 'Due at %s.', 'pitdesk-repair-crm' ),
				$parts['mileage']
			);
		}

		return '';
	}

	/**
	 * Render the audit + notes timeline, newest first (D-13/D-21).
	 *
	 * Status-change rows read "from -> to by <author> at <time>"; note rows show
	 * the escaped note body with its author and time. Author names and note text
	 * are escaped at the output boundary; the note body is run through
	 * nl2br( esc_html() ) so stored line breaks render without opening an XSS
	 * surface (T-03-18).
	 *
	 * @param int $work_order_id Work-order id.
	 * @return void
	 */
	private static function render_timeline( $work_order_id ) {
		$events   = WrenchPress_Events_Repository::list_for_work_order( $work_order_id );
		$statuses = WrenchPress_Work_Orders_Repository::statuses();

		echo '<h2>' . esc_html__( 'History and notes', 'pitdesk-repair-crm' ) . '</h2>';

		if ( empty( $events ) ) {
			echo '<p>' . esc_html__( 'No history yet.', 'pitdesk-repair-crm' ) . '</p>';
			return;
		}

		echo '<ul class="wrenchpress-timeline">';

		foreach ( $events as $event ) {
			$author = (string) $event->author_name;
			$when   = self::format_datetime( $event->created_at );

			echo '<li>';

			if ( 'status_change' === (string) $event->event_type ) {
				$from_slug = (string) $event->from_status;
				$to_slug   = (string) $event->to_status;
				$from      = isset( $statuses[ $from_slug ] ) ? $statuses[ $from_slug ] : $from_slug;
				$to        = isset( $statuses[ $to_slug ] ) ? $statuses[ $to_slug ] : $to_slug;

				echo '<strong>' . esc_html( $from ) . '</strong> &rarr; <strong>' . esc_html( $to ) . '</strong> ';
				echo esc_html(
					sprintf(
						/* translators: 1: author display name, 2: date and time. */
						__( 'by %1$s at %2$s', 'pitdesk-repair-crm' ),
						$author,
						$when
					)
				);
			} else {
				echo '<div class="wrenchpress-note">' . nl2br( esc_html( (string) $event->note ) ) . '</div>';
				echo '<span class="description">' . esc_html(
					sprintf(
						/* translators: 1: author display name, 2: date and time. */
						__( 'by %1$s at %2$s', 'pitdesk-repair-crm' ),
						$author,
						$when
					)
				) . '</span>';
			}

			echo '</li>';
		}

		echo '</ul>';
	}

	/**
	 * Render the append-only add-note form (D-21).
	 *
	 * Shown to any user who holds ADD_WORKORDER_NOTES, including a mechanic, and
	 * regardless of the work order being closed: a progress note never mutates
	 * priced data, so a closing remark is always allowed.
	 *
	 * @param object $wo Work-order row.
	 * @return void
	 */
	private static function render_note_form( $wo ) {
		if ( ! current_user_can( WrenchPress_Capabilities::ADD_WORKORDER_NOTES ) ) {
			return;
		}

		$id = (int) $wo->id;

		echo '<h2>' . esc_html__( 'Add a note', 'pitdesk-repair-crm' ) . '</h2>';
		echo '<form method="post" action="' . esc_url( self::list_url() ) . '">';
		wp_nonce_field( self::NOTE_NONCE . $id );
		echo '<input type="hidden" name="wrenchpress_action" value="note" />';
		echo '<input type="hidden" name="work_order" value="' . esc_attr( (string) $id ) . '" />';
		echo '<p><label class="screen-reader-text" for="wrenchpress_wo_note">' . esc_html__( 'Note', 'pitdesk-repair-crm' ) . '</label>';
		echo '<textarea name="note" id="wrenchpress_wo_note" rows="3" class="large-text" required></textarea></p>';
		echo '<p><button type="submit" class="button">' . esc_html__( 'Add note', 'pitdesk-repair-crm' ) . '</button></p>';
		echo '</form>';
	}

	/**
	 * Render the line items, totals, and (when editable) the add/edit/delete and
	 * tax controls (WORK-03/D-14/D-15).
	 *
	 * Editing is offered only when the work order is open AND the user holds
	 * MANAGE_WORKORDERS; a closed work order or a mechanic sees the items and
	 * totals read-only with no controls (D-12/D-20). Totals are computed on read
	 * and rounded only at the display boundary.
	 *
	 * @param object $wo Work-order row.
	 * @return void
	 */
	private static function render_line_items( $wo ) {
		$id       = (int) $wo->id;
		$editable = 'closed' !== (string) $wo->status && current_user_can( WrenchPress_Capabilities::MANAGE_WORKORDERS );
		$items    = WrenchPress_Work_Order_Items_Repository::list_for_work_order( $id );
		$types    = array(
			'labor' => __( 'Labor', 'pitdesk-repair-crm' ),
			'part'  => __( 'Part', 'pitdesk-repair-crm' ),
		);

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

		echo '<table class="wp-list-table widefat fixed striped"><thead><tr>';
		echo '<th scope="col">' . esc_html__( 'Type', 'pitdesk-repair-crm' ) . '</th>';
		echo '<th scope="col">' . esc_html__( 'Description', 'pitdesk-repair-crm' ) . '</th>';
		echo '<th scope="col">' . esc_html__( 'Qty', 'pitdesk-repair-crm' ) . '</th>';
		echo '<th scope="col">' . esc_html__( 'Unit price', 'pitdesk-repair-crm' ) . '</th>';
		echo '<th scope="col">' . esc_html__( 'Line total', 'pitdesk-repair-crm' ) . '</th>';
		if ( $editable ) {
			echo '<th scope="col">' . esc_html__( 'Actions', 'pitdesk-repair-crm' ) . '</th>';
		}
		echo '</tr></thead><tbody>';

		if ( empty( $items ) ) {
			$span = $editable ? 6 : 5;
			echo '<tr><td colspan="' . esc_attr( (string) $span ) . '">' . esc_html__( 'No line items yet.', 'pitdesk-repair-crm' ) . '</td></tr>';
		}

		$position = 0;

		foreach ( $items as $item ) {
			++$position;
			$type_label = isset( $types[ (string) $item->item_type ] ) ? $types[ (string) $item->item_type ] : (string) $item->item_type;
			$line_total = (float) $item->quantity * (float) $item->unit_price;

			echo '<tr>';
			echo '<td>' . esc_html( $type_label ) . '</td>';
			echo '<td>' . esc_html( (string) $item->description ) . '</td>';
			echo '<td>' . esc_html( number_format( (float) $item->quantity, 2 ) ) . '</td>';
			echo '<td>' . esc_html( number_format( (float) $item->unit_price, 2 ) ) . '</td>';
			echo '<td>' . esc_html( number_format( $line_total, 2 ) ) . '</td>';

			if ( $editable ) {
				/*
				 * Every Delete button in this table would otherwise carry the identical
				 * accessible name "Delete", so a screen-reader user listing the buttons
				 * cannot tell the rows apart. Name each one after its line item. The
				 * description is user-supplied and lands in a new output context here,
				 * so it is escaped at the point of output.
				 */
				$item_label = trim( (string) $item->description );

				if ( '' === $item_label ) {
					/* translators: %d: position of the line item within the table. */
					$item_label = sprintf( __( 'line item %d', 'pitdesk-repair-crm' ), $position );
				}

				echo '<td>';
				echo '<form method="post" action="' . esc_url( self::list_url() ) . '" style="display:inline">';
				wp_nonce_field( self::CREATE_NONCE . $id );
				echo '<input type="hidden" name="wrenchpress_action" value="delete_item" />';
				echo '<input type="hidden" name="work_order" value="' . esc_attr( (string) $id ) . '" />';
				echo '<input type="hidden" name="item" value="' . esc_attr( (string) (int) $item->id ) . '" />';
				echo '<button type="submit" class="button-link submitdelete">' . esc_html__( 'Delete', 'pitdesk-repair-crm' ) . '<span class="screen-reader-text"> ' . esc_html( $item_label ) . '</span></button>';
				echo '</form>';
				echo '</td>';
			}

			echo '</tr>';
		}

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

		if ( $editable ) {
			self::render_add_item_form( $id, $types );
		}

		self::render_totals( $wo );

		if ( $editable ) {
			self::render_tax_form( $wo );
		}
	}

	/**
	 * Render the add-line-item form.
	 *
	 * @param int                  $id    Work-order id.
	 * @param array<string,string> $types item_type slug => label map.
	 * @return void
	 */
	private static function render_add_item_form( $id, array $types ) {
		echo '<h3>' . esc_html__( 'Add a line item', 'pitdesk-repair-crm' ) . '</h3>';
		echo '<form method="post" action="' . esc_url( self::list_url() ) . '">';
		wp_nonce_field( self::CREATE_NONCE . $id );
		echo '<input type="hidden" name="wrenchpress_action" value="add_item" />';
		echo '<input type="hidden" name="work_order" value="' . esc_attr( (string) $id ) . '" />';

		echo '<table class="form-table" role="presentation"><tbody>';

		echo '<tr><th scope="row"><label for="wrenchpress_item_type">' . esc_html__( 'Type', 'pitdesk-repair-crm' ) . '</label></th><td>';
		echo '<select name="item_type" id="wrenchpress_item_type">';
		foreach ( $types as $slug => $label ) {
			echo '<option value="' . esc_attr( $slug ) . '">' . esc_html( $label ) . '</option>';
		}
		echo '</select></td></tr>';

		echo '<tr><th scope="row"><label for="wrenchpress_item_desc">' . esc_html__( 'Description', 'pitdesk-repair-crm' ) . '</label></th><td>';
		echo '<input type="text" name="description" id="wrenchpress_item_desc" class="regular-text" /></td></tr>';

		echo '<tr><th scope="row"><label for="wrenchpress_item_qty">' . esc_html__( 'Quantity', 'pitdesk-repair-crm' ) . '</label></th><td>';
		echo '<input type="number" step="0.01" min="0" name="quantity" id="wrenchpress_item_qty" value="1" /></td></tr>';

		echo '<tr><th scope="row"><label for="wrenchpress_item_price">' . esc_html__( 'Unit price', 'pitdesk-repair-crm' ) . '</label></th><td>';
		echo '<input type="number" step="0.01" min="0" name="unit_price" id="wrenchpress_item_price" value="0" /></td></tr>';

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

		echo '<p><button type="submit" class="button">' . esc_html__( 'Add line item', 'pitdesk-repair-crm' ) . '</button></p>';
		echo '</form>';
	}

	/**
	 * Render the computed totals block (D-15).
	 *
	 * @param object $wo Work-order row.
	 * @return void
	 */
	private static function render_totals( $wo ) {
		$totals  = WrenchPress_Work_Order_Items_Repository::totals( (int) $wo->id, $wo->tax_rate );
		$percent = round( (float) $wo->tax_rate * 100, 4 );

		echo '<table class="form-table" role="presentation"><tbody>';
		echo '<tr><th scope="row">' . esc_html__( 'Labor subtotal', 'pitdesk-repair-crm' ) . '</th><td>' . esc_html( number_format( (float) $totals['labor'], 2 ) ) . '</td></tr>';
		echo '<tr><th scope="row">' . esc_html__( 'Parts subtotal', 'pitdesk-repair-crm' ) . '</th><td>' . esc_html( number_format( (float) $totals['parts'], 2 ) ) . '</td></tr>';
		echo '<tr><th scope="row">' . esc_html(
			sprintf(
				/* translators: %s: tax rate as a percentage. */
				__( 'Tax (%s%%)', 'pitdesk-repair-crm' ),
				(string) $percent
			)
		) . '</th><td>' . esc_html( number_format( (float) $totals['tax'], 2 ) ) . '</td></tr>';
		echo '<tr><th scope="row"><strong>' . esc_html__( 'Grand total', 'pitdesk-repair-crm' ) . '</strong></th><td><strong>' . esc_html( number_format( (float) $totals['grand_total'], 2 ) ) . '</strong></td></tr>';
		echo '</tbody></table>';
	}

	/**
	 * Render the per-work-order tax-rate override form (D-15).
	 *
	 * @param object $wo Work-order row.
	 * @return void
	 */
	private static function render_tax_form( $wo ) {
		$id      = (int) $wo->id;
		$percent = round( (float) $wo->tax_rate * 100, 4 );

		echo '<form method="post" action="' . esc_url( self::list_url() ) . '">';
		wp_nonce_field( self::CREATE_NONCE . $id );
		echo '<input type="hidden" name="wrenchpress_action" value="update_tax_rate" />';
		echo '<input type="hidden" name="work_order" value="' . esc_attr( (string) $id ) . '" />';
		echo '<label for="wrenchpress_wo_tax">' . esc_html__( 'Tax rate (%)', 'pitdesk-repair-crm' ) . '</label> ';
		echo '<input type="number" step="0.0001" min="0" max="100" name="tax_rate" id="wrenchpress_wo_tax" value="' . esc_attr( (string) $percent ) . '" /> ';
		echo '<button type="submit" class="button">' . esc_html__( 'Update tax rate', 'pitdesk-repair-crm' ) . '</button>';
		echo '</form>';
	}

	/**
	 * Render the success / status notice for the detail-level PRG flags.
	 *
	 * @return void
	 */
	private static function maybe_render_detail_notice() {
		// phpcs:disable WordPress.Security.NonceVerification.Recommended -- status flags come from our own PRG redirects.
		if ( isset( $_GET['created'] ) ) {
			$message = __( 'Work order created.', 'pitdesk-repair-crm' );
			$class   = 'notice-success';
		} elseif ( isset( $_GET['status_changed'] ) ) {
			$message = __( 'Status updated.', 'pitdesk-repair-crm' );
			$class   = 'notice-success';
		} elseif ( isset( $_GET['reopened'] ) ) {
			$message = __( 'Work order reopened.', 'pitdesk-repair-crm' );
			$class   = 'notice-success';
		} elseif ( isset( $_GET['note_added'] ) ) {
			$message = __( 'Note added.', 'pitdesk-repair-crm' );
			$class   = 'notice-success';
		} elseif ( isset( $_GET['item_added'] ) ) {
			$message = __( 'Line item added.', 'pitdesk-repair-crm' );
			$class   = 'notice-success';
		} elseif ( isset( $_GET['item_updated'] ) ) {
			$message = __( 'Line item updated.', 'pitdesk-repair-crm' );
			$class   = 'notice-success';
		} elseif ( isset( $_GET['item_deleted'] ) ) {
			$message = __( 'Line item deleted.', 'pitdesk-repair-crm' );
			$class   = 'notice-success';
		} elseif ( isset( $_GET['tax_updated'] ) ) {
			$message = __( 'Tax rate updated.', 'pitdesk-repair-crm' );
			$class   = 'notice-success';
		} elseif ( isset( $_GET['reminder_set'] ) ) {
			$message = __( 'Reminder set.', 'pitdesk-repair-crm' );
			$class   = 'notice-success';
		} elseif ( isset( $_GET['note_empty'] ) ) {
			$message = __( 'A note cannot be empty.', 'pitdesk-repair-crm' );
			$class   = 'notice-error';
		} elseif ( isset( $_GET['reminder_empty'] ) ) {
			$message = __( 'Enter a due date or a due mileage before setting the reminder.', 'pitdesk-repair-crm' );
			$class   = 'notice-error';
		} elseif ( isset( $_GET['reminder_error'] ) ) {
			$message = __( 'The reminder could not be saved. Please try again.', 'pitdesk-repair-crm' );
			$class   = 'notice-error';
		} elseif ( isset( $_GET['item_invalid'] ) ) {
			$message = __( 'Enter a valid amount (plain numbers only).', 'pitdesk-repair-crm' );
			$class   = 'notice-error';
		} elseif ( isset( $_GET['locked'] ) ) {
			$message = __( 'This work order is closed and locked. Reopen it to make changes.', 'pitdesk-repair-crm' );
			$class   = 'notice-error';
		} elseif ( isset( $_GET['error'] ) ) {
			$message = __( 'That action could not be completed.', '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>';
	}

	/**
	 * URL of the Work Orders 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 );
	}
}
