<?php
/**
 * REST controller for the picker search endpoints.
 *
 * These two read-only routes exist to kill the 500-row picker cap. Every customer and
 * vehicle picker in wp-admin used to be fed a pre-baked list of the first 500 active rows,
 * so a shop with more than 500 customers could not select customer 501 anywhere: the row
 * was in the database and unreachable from the UI. Search moves that filtering into SQL,
 * where there is no cap.
 *
 * SECURITY, AND WHERE IT LIVES:
 *
 *   - Each route declares its own explicit permission_callback calling current_user_can()
 *     with a wrenchpress_* capability. Never __return_true, and never a core capability:
 *     manage_options would let an administrator through while locking out the front-desk
 *     user the feature is FOR, and it would say nothing about who may read customer data.
 *
 *   - VIEW_CUSTOMERS / VIEW_VEHICLES are the correct caps and widen no audience (T-OTQ-02).
 *     Every role holding them can already read the same rows, with the same phone, email,
 *     VIN, plate and engine number, on the Customers and Vehicles list screens. This is a
 *     second door onto data the caller can already reach, not a new room.
 *
 *   - Every argument is declared in the route's args map with a sanitize_callback, so a
 *     callback never sees raw input. The LIKE escaping and the column whitelist live in
 *     the repositories, which are the last line before the query.
 *
 *   - Reads only, so there is no state to forge (T-OTQ-05). The islands still send the
 *     wp_rest nonce as X-WP-Nonce, which is what makes current_user_can() see a logged-in
 *     user rather than an anonymous one.
 *
 * @package WrenchPress
 */

defined( 'ABSPATH' ) || exit;

/**
 * Class WrenchPress_Search_Rest_Controller
 */
class WrenchPress_Search_Rest_Controller {

	/**
	 * REST namespace for both search routes.
	 */
	const REST_NAMESPACE = 'wrenchpress/v1';

	/**
	 * Default page size.
	 *
	 * Twenty, and it is a decision rather than a guess (D-OTQ-03). ComboboxControl renders
	 * the ENTIRE options array into its listbox with no virtualisation, and announces
	 * "N results found" to a screen reader. Twenty rows is already more than a person scans
	 * before refining the query, and short enough to arrow through. Every response also
	 * carries the true total, so the UI can say "showing 20 of 312, keep typing" instead of
	 * pretending 20 is all there is.
	 */
	const DEFAULT_PER_PAGE = 20;

	/**
	 * Hard ceiling on page size.
	 *
	 * Not a preference: a crafted per_page=100000 would otherwise turn a picker endpoint
	 * into a full table dump of every customer's phone and email (T-OTQ-04). Clamped here
	 * AND again in the repository.
	 */
	const MAX_PER_PAGE = 50;

	/**
	 * Register both search routes on rest_api_init.
	 *
	 *   GET /wrenchpress/v1/search/customers?search=&per_page=
	 *   GET /wrenchpress/v1/search/vehicles?search=&customer_id=&per_page=
	 *
	 * @return void
	 */
	public static function register_routes() {
		$namespace = self::REST_NAMESPACE;

		register_rest_route(
			$namespace,
			'/search/customers',
			array(
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( __CLASS__, 'search_customers' ),
					'permission_callback' => array( __CLASS__, 'can_view_customers' ),
					'args'                => array(
						'search'   => array(
							'sanitize_callback' => 'sanitize_text_field',
						),
						'per_page' => array(
							'sanitize_callback' => 'absint',
						),
					),
				),
			)
		);

		register_rest_route(
			$namespace,
			'/search/vehicles',
			array(
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( __CLASS__, 'search_vehicles' ),
					'permission_callback' => array( __CLASS__, 'can_view_vehicles' ),
					'args'                => array(
						'search'      => array(
							'sanitize_callback' => 'sanitize_text_field',
						),
						'customer_id' => array(
							'sanitize_callback' => 'absint',
						),
						'per_page'    => array(
							'sanitize_callback' => 'absint',
						),
					),
				),
			)
		);
	}

	/**
	 * Permission callback for the customer search route.
	 *
	 * @return bool
	 */
	public static function can_view_customers() {
		return current_user_can( WrenchPress_Capabilities::VIEW_CUSTOMERS );
	}

	/**
	 * Permission callback for the vehicle search route.
	 *
	 * @return bool
	 */
	public static function can_view_vehicles() {
		return current_user_can( WrenchPress_Capabilities::VIEW_VEHICLES );
	}

	/**
	 * Clamp the requested page size to 1..MAX_PER_PAGE.
	 *
	 * @param WP_REST_Request $request Incoming request.
	 * @return int
	 */
	private static function per_page( WP_REST_Request $request ) {
		$per_page = absint( $request->get_param( 'per_page' ) );

		if ( 0 === $per_page ) {
			return self::DEFAULT_PER_PAGE;
		}

		return max( 1, min( self::MAX_PER_PAGE, $per_page ) );
	}

	/**
	 * GET /search/customers: find active customers by name, phone or email.
	 *
	 * @param WP_REST_Request $request Incoming request.
	 * @return WP_REST_Response
	 */
	public static function search_customers( WP_REST_Request $request ) {
		$search   = (string) $request->get_param( 'search' );
		$per_page = self::per_page( $request );

		$rows = WrenchPress_Customers_Repository::search_active(
			array(
				'search'   => $search,
				'per_page' => $per_page,
			)
		);

		$items = array();

		foreach ( $rows as $row ) {
			/*
			 * phone and email ride along because the CLIENT cannot annotate a match reason
			 * it was never sent: core's ComboboxControl re-filters the options array by
			 * label containment, so a row the SERVER matched on a phone number is thrown
			 * away before it renders unless the client can put that phone into the label.
			 * These are the same values the caller can already read on the Customers list
			 * screen, which the same capability gates (T-OTQ-02).
			 */
			$items[] = array(
				'id'    => (int) $row->id,
				'label' => WrenchPress_Customers_Repository::label( $row ),
				'phone' => (string) $row->phone,
				'email' => (string) $row->email,
			);
		}

		return new WP_REST_Response(
			array(
				'items' => $items,
				'total' => WrenchPress_Customers_Repository::count_search_active(
					array( 'search' => $search )
				),
			),
			200
		);
	}

	/**
	 * GET /search/vehicles: find active vehicles by year, make, model, VIN, plate or
	 * engine number, shop-wide or narrowed to one owning customer.
	 *
	 * The customer_id argument FILTERS, it does not authorise (T-OTQ-03): a caller who can
	 * list vehicles at all can already list every vehicle on the Vehicles screen.
	 *
	 * @param WP_REST_Request $request Incoming request.
	 * @return WP_REST_Response
	 */
	public static function search_vehicles( WP_REST_Request $request ) {
		$search      = (string) $request->get_param( 'search' );
		$customer_id = absint( $request->get_param( 'customer_id' ) );
		$per_page    = self::per_page( $request );

		$rows = WrenchPress_Vehicles_Repository::search_active(
			array(
				'search'      => $search,
				'customer_id' => $customer_id,
				'per_page'    => $per_page,
			)
		);

		$items = array();

		foreach ( $rows as $row ) {
			$owner = WrenchPress_Customers_Repository::get( (int) $row->customer_id );

			$items[] = array(
				'id'             => (int) $row->id,
				// The single-source label helper, so a search result and the Vehicles list
				// and a calendar event all render the same car the same way.
				'label'          => WrenchPress_Vehicles_List_Table::vehicle_label( $row ),
				'customer_id'    => (int) $row->customer_id,
				'customer_label' => WrenchPress_Customers_Repository::label( $owner ),
				'vin'            => (string) $row->vin,
				'plate'          => (string) $row->plate,
				'engine_number'  => (string) $row->engine_number,
			);
		}

		return new WP_REST_Response(
			array(
				'items' => $items,
				'total' => WrenchPress_Vehicles_Repository::count_search_active(
					array(
						'search'      => $search,
						'customer_id' => $customer_id,
					)
				),
			),
			200
		);
	}
}
