<?php
/**
 * Data-access layer for vehicle records.
 *
 * All vehicle reads and writes go through this repository using $wpdb with
 * prepared statements and explicit format arrays. No caller interpolates user
 * input into SQL.
 *
 * @package WrenchPress
 */

defined( 'ABSPATH' ) || exit;

/**
 * Class WrenchPress_Vehicles_Repository
 */
class WrenchPress_Vehicles_Repository {

	/**
	 * Columns allowed in ORDER BY, mapped from a request key to a safe SQL
	 * fragment. Never interpolate raw orderby input; map through this list.
	 *
	 * @return array<string, string> Request key => safe ORDER BY fragment.
	 */
	public static function sortable_map() {
		return array(
			'vehicle'    => 'make, model',
			'year'       => 'year',
			'created_at' => 'created_at',
		);
	}

	/**
	 * Fully qualified vehicles table name.
	 *
	 * @return string
	 */
	protected static function table() {
		global $wpdb;

		return $wpdb->prefix . 'wrenchpress_vehicles';
	}

	/**
	 * Insert a new active vehicle.
	 *
	 * Binds every value through an explicit $formats array (the proven shape from
	 * the lead-promotion vehicle insert), defaults status to active, and stamps
	 * created_at / updated_at. Returns the new row id, or false on failure or a
	 * missing owning customer.
	 *
	 * @param array $data customer_id plus optional make, model, year, vin, plate,
	 *                    mileage, fuel_type, transmission, color, engine (the
	 *                    engine spec), engine_number (the engine serial number).
	 * @return int|false New vehicle id, or false on insert failure / bad customer.
	 */
	public static function create( array $data ) {
		global $wpdb;

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

		if ( 0 === $customer_id ) {
			return false;
		}

		$now = current_time( 'mysql' );

		$row = array(
			'customer_id'   => $customer_id,
			'make'          => isset( $data['make'] ) ? (string) $data['make'] : '',
			'model'         => isset( $data['model'] ) ? (string) $data['model'] : '',
			'year'          => isset( $data['year'] ) ? absint( $data['year'] ) : 0,
			'vin'           => isset( $data['vin'] ) ? (string) $data['vin'] : '',
			'plate'         => isset( $data['plate'] ) ? (string) $data['plate'] : '',
			'mileage'       => isset( $data['mileage'] ) ? absint( $data['mileage'] ) : 0,
			'fuel_type'     => isset( $data['fuel_type'] ) ? (string) $data['fuel_type'] : '',
			'transmission'  => isset( $data['transmission'] ) ? (string) $data['transmission'] : '',
			'color'         => isset( $data['color'] ) ? (string) $data['color'] : '',
			'engine'        => isset( $data['engine'] ) ? (string) $data['engine'] : '',
			'engine_number' => isset( $data['engine_number'] ) ? (string) $data['engine_number'] : '',
			'status'        => 'active',
			'created_at'    => $now,
			'updated_at'    => $now,
		);

		// Positional: each format lines up with the $row key at the same index. A
		// misaligned entry silently writes the wrong column.
		$formats = array(
			'%d', // customer_id.
			'%s', // make.
			'%s', // model.
			'%d', // year.
			'%s', // vin.
			'%s', // plate.
			'%d', // mileage.
			'%s', // fuel_type.
			'%s', // transmission.
			'%s', // color.
			'%s', // engine.
			'%s', // engine_number.
			'%s', // status.
			'%s', // created_at.
			'%s', // updated_at.
		);

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom plugin table write via the $wpdb array API, intentionally not object-cached.
		$inserted = $wpdb->insert( self::table(), $row, $formats );

		if ( false === $inserted ) {
			return false;
		}

		return (int) $wpdb->insert_id;
	}

	/**
	 * Fetch a single vehicle by id.
	 *
	 * @param int $id Vehicle id.
	 * @return object|null Row object, or null when not found.
	 */
	public static function get( $id ) {
		global $wpdb;

		$id = absint( $id );

		if ( 0 === $id ) {
			return null;
		}

		$table = self::table();

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom plugin table, intentionally not object-cached.
		$row = $wpdb->get_row(
			$wpdb->prepare( 'SELECT * FROM %i WHERE id = %d', $table, $id )
		);

		return $row ? $row : null;
	}

	/**
	 * Update an existing vehicle. Recomputes updated_at.
	 *
	 * @param int   $id   Vehicle id.
	 * @param array $data make, model, year, vin, plate, mileage, fuel_type,
	 *                    transmission, color, engine (the engine spec),
	 *                    engine_number (the engine serial number).
	 * @return bool True on success.
	 */
	public static function update( $id, array $data ) {
		global $wpdb;

		$id = absint( $id );

		if ( 0 === $id ) {
			return false;
		}

		$row = array(
			'make'          => isset( $data['make'] ) ? (string) $data['make'] : '',
			'model'         => isset( $data['model'] ) ? (string) $data['model'] : '',
			'year'          => isset( $data['year'] ) ? absint( $data['year'] ) : 0,
			'vin'           => isset( $data['vin'] ) ? (string) $data['vin'] : '',
			'plate'         => isset( $data['plate'] ) ? (string) $data['plate'] : '',
			'mileage'       => isset( $data['mileage'] ) ? absint( $data['mileage'] ) : 0,
			'fuel_type'     => isset( $data['fuel_type'] ) ? (string) $data['fuel_type'] : '',
			'transmission'  => isset( $data['transmission'] ) ? (string) $data['transmission'] : '',
			'color'         => isset( $data['color'] ) ? (string) $data['color'] : '',
			'engine'        => isset( $data['engine'] ) ? (string) $data['engine'] : '',
			'engine_number' => isset( $data['engine_number'] ) ? (string) $data['engine_number'] : '',
			'updated_at'    => current_time( 'mysql' ),
		);

		// Positional, and it must stay aligned with $row above: make, model, year,
		// vin, plate, mileage, fuel_type, transmission, color, engine,
		// engine_number, updated_at.
		$formats = array( '%s', '%s', '%d', '%s', '%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s' );

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom plugin table write via the $wpdb array API, intentionally not object-cached.
		$result = $wpdb->update( self::table(), $row, array( 'id' => $id ), $formats, array( '%d' ) );

		return false !== $result;
	}

	/**
	 * Anonymize the identifying data on every vehicle owned by a customer (D-06).
	 *
	 * Strips the fields that identify a specific vehicle or its owner (VIN, plate,
	 * engine serial number) while KEEPING make, model, and year so the shop's job
	 * history stays meaningful. The rows themselves are retained because work
	 * orders reference them. Runs one prepared UPDATE scoped by customer_id.
	 *
	 * @param int $customer_id Owning customer id.
	 * @return int Rows affected (0 on invalid id or when the customer owns none).
	 */
	public static function anonymize_for_customer( $customer_id ) {
		global $wpdb;

		$customer_id = absint( $customer_id );

		if ( 0 === $customer_id ) {
			return 0;
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom plugin table write via the $wpdb array API, intentionally not object-cached.
		$result = $wpdb->update(
			self::table(),
			array(
				'vin'           => '',
				'plate'         => '',
				'engine_number' => '',
				'updated_at'    => current_time( 'mysql' ),
			),
			array( 'customer_id' => $customer_id ),
			array( '%s', '%s', '%s', '%s' ),
			array( '%d' )
		);

		return is_int( $result ) ? $result : 0;
	}

	/**
	 * List every vehicle owned by a customer, active AND archived (PRIV-01).
	 *
	 * Status-agnostic on purpose. The GDPR personal-data exporter must surface a
	 * soft-deleted vehicle's VIN and plate, mirroring anonymize_for_customer(),
	 * which already strips those identifiers regardless of status; exporting only
	 * active vehicles while erasing all of them is the asymmetry this closes. No
	 * search, sort, or pagination options: the exporter wants the full owned set.
	 * The customer-facing portal dashboard keeps calling list_active(), so an
	 * archived vehicle stays hidden there.
	 *
	 * @param int $customer_id Owning customer id.
	 * @return array List of row objects (active and archived), oldest first.
	 */
	public static function list_all_for_customer( $customer_id ) {
		global $wpdb;

		$customer_id = absint( $customer_id );

		if ( 0 === $customer_id ) {
			return array();
		}

		$table = self::table();

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom plugin table, intentionally not object-cached.
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				'SELECT * FROM %i WHERE customer_id = %d ORDER BY id ASC',
				$table,
				$customer_id
			)
		);

		return is_array( $rows ) ? $rows : array();
	}

	/**
	 * Hard-delete every vehicle owned by a customer.
	 *
	 * Reserved for the GDPR eraser's discretion branch: when a customer has NO
	 * work-order or document history there is nothing to reference the vehicle
	 * rows, so they may be removed rather than anonymized. Never call this for a
	 * customer with job history; anonymize_for_customer() in place instead (D-06).
	 *
	 * @param int $customer_id Owning customer id.
	 * @return int Rows deleted (0 on invalid id or DB failure).
	 */
	public static function delete_for_customer( $customer_id ) {
		global $wpdb;

		$customer_id = absint( $customer_id );

		if ( 0 === $customer_id ) {
			return 0;
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom plugin table write via the $wpdb array API, intentionally not object-cached.
		$deleted = $wpdb->delete( self::table(), array( 'customer_id' => $customer_id ), array( '%d' ) );

		return is_int( $deleted ) ? $deleted : 0;
	}

	/**
	 * Soft-delete (archive) a vehicle. The row is retained (D-16); no hard DELETE
	 * is ever issued against the vehicles table.
	 *
	 * @param int $id Vehicle id.
	 * @return bool True on success.
	 */
	public static function archive( $id ) {
		global $wpdb;

		$id = absint( $id );

		if ( 0 === $id ) {
			return false;
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom plugin table write via the $wpdb array API, intentionally not object-cached.
		$result = $wpdb->update(
			self::table(),
			array(
				'status'     => 'archived',
				'updated_at' => current_time( 'mysql' ),
			),
			array( 'id' => $id ),
			array( '%s', '%s' ),
			array( '%d' )
		);

		return false !== $result;
	}

	/**
	 * List active vehicles, honoring search, whitelisted sort, and pagination.
	 *
	 * @param array $args {
	 *     Optional query arguments.
	 *
	 *     @type int    $customer_id Restrict to one owning customer. Default 0
	 *                              (all customers).
	 *     @type string $search   Search term applied to make, model, plate, vin.
	 *     @type string $orderby  Request orderby key: 'vehicle', 'year', or
	 *                            'created_at'; anything else falls back to default.
	 *     @type string $order    ASC or DESC.
	 *     @type int    $per_page Rows per page. Default 20.
	 *     @type int    $offset   Row offset. Default 0.
	 * }
	 * @return array List of row objects.
	 */
	public static function list_active( array $args = array() ) {
		global $wpdb;

		$per_page    = isset( $args['per_page'] ) ? max( 1, absint( $args['per_page'] ) ) : 20;
		$offset      = isset( $args['offset'] ) ? max( 0, absint( $args['offset'] ) ) : 0;
		$search      = isset( $args['search'] ) ? trim( (string) $args['search'] ) : '';
		$customer_id = isset( $args['customer_id'] ) ? absint( $args['customer_id'] ) : 0;
		$orderby     = isset( $args['orderby'] ) ? (string) $args['orderby'] : 'vehicle';
		$order       = isset( $args['order'] ) ? (string) $args['order'] : 'ASC';

		$table = self::table();

		// Whitelisted sort columns, passed to prepare() as %i identifiers. Unknown
		// keys fall back to make, model in ascending order regardless of $order.
		if ( 'created_at' === $orderby ) {
			$col1 = 'created_at';
			$col2 = 'id';
			$desc = ( 'DESC' === strtoupper( $order ) );
		} elseif ( 'year' === $orderby ) {
			$col1 = 'year';
			$col2 = 'id';
			$desc = ( 'DESC' === strtoupper( $order ) );
		} elseif ( 'vehicle' === $orderby ) {
			$col1 = 'make';
			$col2 = 'model';
			$desc = ( 'DESC' === strtoupper( $order ) );
		} else {
			$col1 = 'make';
			$col2 = 'model';
			$desc = false;
		}

		if ( $customer_id > 0 && '' !== $search ) {
			$like = '%' . $wpdb->esc_like( $search ) . '%';

			if ( $desc ) {
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom plugin table, intentionally not object-cached.
				$rows = $wpdb->get_results(
					$wpdb->prepare(
						'SELECT * FROM %i WHERE status = %s AND customer_id = %d AND ( make LIKE %s OR model LIKE %s OR plate LIKE %s OR vin LIKE %s ) ORDER BY %i DESC, %i DESC LIMIT %d OFFSET %d',
						$table,
						'active',
						$customer_id,
						$like,
						$like,
						$like,
						$like,
						$col1,
						$col2,
						$per_page,
						$offset
					)
				);
			} else {
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom plugin table, intentionally not object-cached.
				$rows = $wpdb->get_results(
					$wpdb->prepare(
						'SELECT * FROM %i WHERE status = %s AND customer_id = %d AND ( make LIKE %s OR model LIKE %s OR plate LIKE %s OR vin LIKE %s ) ORDER BY %i ASC, %i ASC LIMIT %d OFFSET %d',
						$table,
						'active',
						$customer_id,
						$like,
						$like,
						$like,
						$like,
						$col1,
						$col2,
						$per_page,
						$offset
					)
				);
			}

			return is_array( $rows ) ? $rows : array();
		}

		if ( $customer_id > 0 ) {
			if ( $desc ) {
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom plugin table, intentionally not object-cached.
				$rows = $wpdb->get_results(
					$wpdb->prepare(
						'SELECT * FROM %i WHERE status = %s AND customer_id = %d ORDER BY %i DESC, %i DESC LIMIT %d OFFSET %d',
						$table,
						'active',
						$customer_id,
						$col1,
						$col2,
						$per_page,
						$offset
					)
				);
			} else {
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom plugin table, intentionally not object-cached.
				$rows = $wpdb->get_results(
					$wpdb->prepare(
						'SELECT * FROM %i WHERE status = %s AND customer_id = %d ORDER BY %i ASC, %i ASC LIMIT %d OFFSET %d',
						$table,
						'active',
						$customer_id,
						$col1,
						$col2,
						$per_page,
						$offset
					)
				);
			}

			return is_array( $rows ) ? $rows : array();
		}

		if ( '' !== $search ) {
			$like = '%' . $wpdb->esc_like( $search ) . '%';

			if ( $desc ) {
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom plugin table, intentionally not object-cached.
				$rows = $wpdb->get_results(
					$wpdb->prepare(
						'SELECT * FROM %i WHERE status = %s AND ( make LIKE %s OR model LIKE %s OR plate LIKE %s OR vin LIKE %s ) ORDER BY %i DESC, %i DESC LIMIT %d OFFSET %d',
						$table,
						'active',
						$like,
						$like,
						$like,
						$like,
						$col1,
						$col2,
						$per_page,
						$offset
					)
				);
			} else {
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom plugin table, intentionally not object-cached.
				$rows = $wpdb->get_results(
					$wpdb->prepare(
						'SELECT * FROM %i WHERE status = %s AND ( make LIKE %s OR model LIKE %s OR plate LIKE %s OR vin LIKE %s ) ORDER BY %i ASC, %i ASC LIMIT %d OFFSET %d',
						$table,
						'active',
						$like,
						$like,
						$like,
						$like,
						$col1,
						$col2,
						$per_page,
						$offset
					)
				);
			}

			return is_array( $rows ) ? $rows : array();
		}

		if ( $desc ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom plugin table, intentionally not object-cached.
			$rows = $wpdb->get_results(
				$wpdb->prepare(
					'SELECT * FROM %i WHERE status = %s ORDER BY %i DESC, %i DESC LIMIT %d OFFSET %d',
					$table,
					'active',
					$col1,
					$col2,
					$per_page,
					$offset
				)
			);
		} else {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom plugin table, intentionally not object-cached.
			$rows = $wpdb->get_results(
				$wpdb->prepare(
					'SELECT * FROM %i WHERE status = %s ORDER BY %i ASC, %i ASC LIMIT %d OFFSET %d',
					$table,
					'active',
					$col1,
					$col2,
					$per_page,
					$offset
				)
			);
		}

		return is_array( $rows ) ? $rows : array();
	}

	/**
	 * The ONLY columns a vehicle search may look at. Hardcoded here, on purpose.
	 *
	 * No caller supplies a column name, ever. The search endpoint accepts a search string
	 * and an optional owning-customer id, and nothing else. That is what makes the %i
	 * identifier placeholders below safe.
	 *
	 * engine_number is on the list because a front-desk user holding an engine block's
	 * serial number is exactly the person who cannot find the car any other way.
	 *
	 * @return string[] Searchable column names.
	 */
	private static function searchable_columns() {
		return array( 'year', 'make', 'model', 'vin', 'plate', 'engine_number' );
	}

	/**
	 * Split a raw search string into at most four whitespace-separated tokens.
	 *
	 * Truncated to 100 characters and capped at 4 tokens (T-OTQ-04): a leading-wildcard
	 * LIKE cannot use an index, so each token is a full column scan.
	 *
	 * @param mixed $search Raw search input.
	 * @return string[] Tokens (empty when nothing was typed).
	 */
	private static function search_tokens( $search ) {
		$search = trim( (string) $search );

		if ( '' === $search ) {
			return array();
		}

		$search = substr( $search, 0, 100 );
		$tokens = preg_split( '/\s+/', $search, -1, PREG_SPLIT_NO_EMPTY );

		if ( ! is_array( $tokens ) ) {
			return array();
		}

		return array_slice( $tokens, 0, 4 );
	}

	/**
	 * Build the token-AND WHERE fragment, and the args that go with it.
	 *
	 * THE SECURITY-CRITICAL PART OF THIS CLASS. Read it before changing it.
	 *
	 * The returned SQL string contains NO USER DATA: only the literals '%i', '%s', 'LIKE',
	 * 'OR' and 'AND'. Every column name is a %i identifier drawn from the hardcoded
	 * whitelist above; every value is a %s that has been through $wpdb->esc_like() BEFORE
	 * it reaches prepare(). prepare() alone is not enough here: it escapes the value into
	 * the SQL safely but does not neutralise the % and _ wildcards inside a LIKE pattern,
	 * so an unescaped '%' would expand to "match everything" and this endpoint would dump
	 * every VIN, plate and engine number in the shop to anyone who could type one
	 * character into a picker.
	 *
	 * TOKEN-AND, ORed across columns, because "2019 honda" is the most natural query there
	 * is and matches NO single column: year, make and model are separate. The old
	 * client-side filter matched the rendered LABEL, so this worked; token-AND is what
	 * keeps it working now that the filtering happens in SQL.
	 *
	 * @param string[] $tokens Search tokens.
	 * @return array{0:string,1:array} The WHERE fragment ('' when there are no tokens) and its args.
	 */
	private static function search_where( array $tokens ) {
		global $wpdb;

		if ( empty( $tokens ) ) {
			return array( '', array() );
		}

		$columns   = self::searchable_columns();
		$fragments = array();
		$args      = array();

		foreach ( $tokens as $token ) {
			$like  = '%' . $wpdb->esc_like( (string) $token ) . '%';
			$parts = array();

			foreach ( $columns as $column ) {
				$parts[] = '%i LIKE %s';
				$args[]  = $column;
				$args[]  = $like;
			}

			$fragments[] = '( ' . implode( ' OR ', $parts ) . ' )';
		}

		return array( implode( ' AND ', $fragments ), $args );
	}

	/**
	 * Search active vehicles with a multi-token, prepared, esc_like'd LIKE query.
	 *
	 * Purpose-built for the picker search endpoint and deliberately NOT folded into
	 * list_active() (D-OTQ-01), which is single-token, has no engine_number, and is what
	 * four other screens already depend on.
	 *
	 * An empty search returns the plain status-filtered (and optionally customer-narrowed)
	 * first page, so a freshly focused picker fills its dropdown.
	 *
	 * @param array $args {
	 *     Optional query arguments.
	 *
	 *     @type string $search      Search term. Whitespace-split, token-ANDed.
	 *     @type int    $customer_id Narrow to one owning customer. 0 means shop-wide.
	 *     @type int    $per_page    Rows per page. Default 20, clamped to 1..50.
	 *     @type int    $offset      Row offset. Default 0.
	 * }
	 * @return array List of row objects.
	 */
	public static function search_active( array $args = array() ) {
		global $wpdb;

		// Clamped in the repository as well as in the controller. Defence in depth.
		$per_page    = isset( $args['per_page'] ) ? max( 1, min( 50, absint( $args['per_page'] ) ) ) : 20;
		$offset      = isset( $args['offset'] ) ? max( 0, absint( $args['offset'] ) ) : 0;
		$customer_id = isset( $args['customer_id'] ) ? absint( $args['customer_id'] ) : 0;
		$tokens      = self::search_tokens( isset( $args['search'] ) ? $args['search'] : '' );

		list( $where, $where_args ) = self::search_where( $tokens );

		$sql        = 'SELECT * FROM %i WHERE status = %s';
		$query_args = array( self::table(), 'active' );

		if ( $customer_id > 0 ) {
			$sql         .= ' AND customer_id = %d';
			$query_args[] = $customer_id;
		}

		if ( '' !== $where ) {
			$sql       .= ' AND ' . $where;
			$query_args = array_merge( $query_args, $where_args );
		}

		$sql         .= ' ORDER BY make ASC, model ASC, id ASC LIMIT %d OFFSET %d';
		$query_args[] = $per_page;
		$query_args[] = $offset;

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $where is assembled from constant placeholders only; every column is from a hardcoded whitelist and every value is esc_like'd and passed through prepare(). Custom plugin table, intentionally not object-cached.
		$rows = $wpdb->get_results( $wpdb->prepare( $sql, $query_args ) );

		return is_array( $rows ) ? $rows : array();
	}

	/**
	 * Count every active vehicle matching the same predicate as search_active().
	 *
	 * Built from the SAME private fragment builder, because two copies of a search
	 * predicate drift, and a total that disagrees with the rows is a lie on screen.
	 *
	 * @param array $args Same shape as search_active(); search and customer_id affect the count.
	 * @return int Count of matching active vehicles.
	 */
	public static function count_search_active( array $args = array() ) {
		global $wpdb;

		$customer_id = isset( $args['customer_id'] ) ? absint( $args['customer_id'] ) : 0;
		$tokens      = self::search_tokens( isset( $args['search'] ) ? $args['search'] : '' );

		list( $where, $where_args ) = self::search_where( $tokens );

		$sql        = 'SELECT COUNT(*) FROM %i WHERE status = %s';
		$query_args = array( self::table(), 'active' );

		if ( $customer_id > 0 ) {
			$sql         .= ' AND customer_id = %d';
			$query_args[] = $customer_id;
		}

		if ( '' !== $where ) {
			$sql       .= ' AND ' . $where;
			$query_args = array_merge( $query_args, $where_args );
		}

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $where is assembled from constant placeholders only; every column is from a hardcoded whitelist and every value is esc_like'd and passed through prepare(). Custom plugin table, intentionally not object-cached.
		return (int) $wpdb->get_var( $wpdb->prepare( $sql, $query_args ) );
	}

	/**
	 * Count active vehicles matching the same filter as list_active().
	 *
	 * @param array $args Same shape as list_active(); only search and customer_id
	 *                    affect the count.
	 * @return int Count of active vehicles.
	 */
	public static function count_active( array $args = array() ) {
		global $wpdb;

		$search      = isset( $args['search'] ) ? trim( (string) $args['search'] ) : '';
		$customer_id = isset( $args['customer_id'] ) ? absint( $args['customer_id'] ) : 0;
		$table       = self::table();

		if ( $customer_id > 0 && '' !== $search ) {
			$like = '%' . $wpdb->esc_like( $search ) . '%';

			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom plugin table, intentionally not object-cached.
			return (int) $wpdb->get_var(
				$wpdb->prepare(
					'SELECT COUNT(*) FROM %i WHERE status = %s AND customer_id = %d AND ( make LIKE %s OR model LIKE %s OR plate LIKE %s OR vin LIKE %s )',
					$table,
					'active',
					$customer_id,
					$like,
					$like,
					$like,
					$like
				)
			);
		}

		if ( $customer_id > 0 ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom plugin table, intentionally not object-cached.
			return (int) $wpdb->get_var(
				$wpdb->prepare(
					'SELECT COUNT(*) FROM %i WHERE status = %s AND customer_id = %d',
					$table,
					'active',
					$customer_id
				)
			);
		}

		if ( '' !== $search ) {
			$like = '%' . $wpdb->esc_like( $search ) . '%';

			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom plugin table, intentionally not object-cached.
			return (int) $wpdb->get_var(
				$wpdb->prepare(
					'SELECT COUNT(*) FROM %i WHERE status = %s AND ( make LIKE %s OR model LIKE %s OR plate LIKE %s OR vin LIKE %s )',
					$table,
					'active',
					$like,
					$like,
					$like,
					$like
				)
			);
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom plugin table, intentionally not object-cached.
		return (int) $wpdb->get_var(
			$wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE status = %s', $table, 'active' )
		);
	}
}
