<?php
/**
 * Data-access layer for customer records.
 *
 * All customer 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_Customers_Repository
 */
class WrenchPress_Customers_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(
			'name'       => 'last_name, first_name',
			'created_at' => 'created_at',
		);
	}

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

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

	/**
	 * Insert a new active customer.
	 *
	 * Computes phone_digits from the supplied phone, sets status to active, and
	 * stamps created_at / updated_at. Returns the new row id, or false on
	 * failure.
	 *
	 * @param array $data Associative array with first_name, last_name, phone,
	 *                    and optional email, address, notes.
	 * @return int|false New customer id, or false on insert failure.
	 */
	public static function create( array $data ) {
		global $wpdb;

		$now   = current_time( 'mysql' );
		$phone = isset( $data['phone'] ) ? (string) $data['phone'] : '';

		$row = array(
			'first_name'   => isset( $data['first_name'] ) ? (string) $data['first_name'] : '',
			'last_name'    => isset( $data['last_name'] ) ? (string) $data['last_name'] : '',
			'phone'        => $phone,
			'phone_digits' => preg_replace( '/\D+/', '', $phone ),
			'email'        => isset( $data['email'] ) ? (string) $data['email'] : '',
			'address'      => isset( $data['address'] ) ? (string) $data['address'] : '',
			'notes'        => isset( $data['notes'] ) ? (string) $data['notes'] : '',
			'status'       => 'active',
			'created_at'   => $now,
			'updated_at'   => $now,
		);

		$formats = array(
			'%s', // first_name.
			'%s', // last_name.
			'%s', // phone.
			'%s', // phone_digits.
			'%s', // email.
			'%s', // address.
			'%s', // notes.
			'%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 customer by id.
	 *
	 * @param int $id Customer 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;
	}

	/**
	 * Find active customers by exact email address.
	 *
	 * Email is intentionally non-unique on the customers table, so this returns
	 * an array of matching active row objects (empty when none match). Consumed
	 * by the magic-link request handler and the privacy exporter/eraser, which
	 * are all keyed on the customer email. The empty-value guard means a blank
	 * email never matches the stored '' defaults.
	 *
	 * @param string $email Email to match; sanitized before binding.
	 * @return array List of matching active customer row objects.
	 */
	public static function find_by_email( $email ) {
		global $wpdb;

		$email = sanitize_email( (string) $email );

		if ( '' === $email ) {
			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 status = %s AND email = %s',
				$table,
				'active',
				$email
			)
		);

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

	/**
	 * Find customers by exact email across EVERY status (active and archived).
	 *
	 * Deliberately status-agnostic, unlike find_by_email(): the GDPR
	 * exporter and eraser must reach a data subject's personal data even when
	 * the customer record has been soft-deleted (archived). Customers are never
	 * hard-deleted in normal operation, so a status filter here would let PII on
	 * an archived row escape an export or erasure request. This method is for the
	 * privacy paths ONLY; the portal magic-link auth path stays on
	 * find_by_email() so an archived customer can never sign in. The empty-value
	 * guard means a blank email never matches the stored '' defaults.
	 *
	 * @param string $email Email to match; sanitized before binding.
	 * @return array List of matching customer row objects regardless of status.
	 */
	public static function find_by_email_all_statuses( $email ) {
		global $wpdb;

		$email = sanitize_email( (string) $email );

		if ( '' === $email ) {
			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 email = %s', $table, $email )
		);

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

	/**
	 * List active customers, honoring search, whitelisted sort, and pagination.
	 *
	 * @param array $args {
	 *     Optional query arguments.
	 *
	 *     @type string $search   Search term applied to name/email (raw) and
	 *                            phone_digits (digit-normalized).
	 *     @type string $orderby  Request orderby key: 'name' or 'created_at';
	 *                            anything else falls back to the default order.
	 *     @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'] ) : '';
		$orderby  = isset( $args['orderby'] ) ? (string) $args['orderby'] : 'name';
		$order    = isset( $args['order'] ) ? (string) $args['order'] : 'ASC';

		$table = self::table();
		$desc  = ( 'DESC' === strtoupper( $order ) );

		// Whitelisted sort columns, passed to prepare() as %i identifiers.
		if ( 'created_at' === $orderby ) {
			$col1 = 'created_at';
			$col2 = 'id';
		} else {
			$col1 = 'last_name';
			$col2 = 'first_name';
		}

		if ( '' === $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 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();
		}

		$like   = '%' . $wpdb->esc_like( $search ) . '%';
		$digits = preg_replace( '/\D+/', '', $search );

		if ( '' !== $digits ) {
			$like_digits = '%' . $wpdb->esc_like( $digits ) . '%';

			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 ( first_name LIKE %s OR last_name LIKE %s OR email LIKE %s OR phone_digits LIKE %s ) ORDER BY %i DESC, %i DESC LIMIT %d OFFSET %d',
						$table,
						'active',
						$like,
						$like,
						$like,
						$like_digits,
						$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 ( first_name LIKE %s OR last_name LIKE %s OR email LIKE %s OR phone_digits LIKE %s ) ORDER BY %i ASC, %i ASC LIMIT %d OFFSET %d',
						$table,
						'active',
						$like,
						$like,
						$like,
						$like_digits,
						$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 AND ( first_name LIKE %s OR last_name LIKE %s OR email LIKE %s ) ORDER BY %i DESC, %i DESC LIMIT %d OFFSET %d',
					$table,
					'active',
					$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 ( first_name LIKE %s OR last_name LIKE %s OR email LIKE %s ) ORDER BY %i ASC, %i ASC LIMIT %d OFFSET %d',
					$table,
					'active',
					$like,
					$like,
					$like,
					$col1,
					$col2,
					$per_page,
					$offset
				)
			);
		}

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

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

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

		if ( '' === $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', $table, 'active' )
			);
		}

		$like   = '%' . $wpdb->esc_like( $search ) . '%';
		$digits = preg_replace( '/\D+/', '', $search );

		if ( '' !== $digits ) {
			$like_digits = '%' . $wpdb->esc_like( $digits ) . '%';

			// 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 ( first_name LIKE %s OR last_name LIKE %s OR email LIKE %s OR phone_digits LIKE %s )',
					$table,
					'active',
					$like,
					$like,
					$like,
					$like_digits
				)
			);
		}

		// 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 ( first_name LIKE %s OR last_name LIKE %s OR email LIKE %s )',
				$table,
				'active',
				$like,
				$like,
				$like
			)
		);
	}

	/**
	 * The ONLY columns a customer search may look at. Hardcoded here, on purpose.
	 *
	 * No caller supplies a column name, ever. The search endpoint takes a search string
	 * and nothing else: there is no orderby arg, no field arg, and no way for a request to
	 * reach a column that is not on this list. That is what keeps the %i identifier
	 * placeholders below trustworthy.
	 *
	 * @return string[] Searchable column names.
	 */
	private static function searchable_columns() {
		return array( 'first_name', 'last_name', 'email', 'phone', 'phone_digits' );
	}

	/**
	 * 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 every token is a full scan of the column; an unbounded
	 * token count would let one crafted query become an arbitrary number of them.
	 *
	 * @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. It is assembled purely from the
	 * literals '%i', '%s', 'LIKE', 'OR' and 'AND'. Every column name is pushed onto the
	 * args array as a %i identifier and comes from the hardcoded whitelist above; every
	 * value is pushed as a %s and has been through $wpdb->esc_like() FIRST.
	 *
	 * esc_like() before prepare(), never after and never instead of. prepare() escapes a
	 * value into the SQL string safely, but it has no idea the value is destined for a
	 * LIKE pattern: it does not neutralise the % and _ wildcards inside it. A search for a
	 * literal '%' would otherwise expand into "match everything" and the endpoint would
	 * hand back every customer's phone and email to anyone who could type one character
	 * into a picker. esc_like() is the only thing that stops that, and search-api.spec.js
	 * asserts it with a negative control precisely because the bug is invisible on
	 * inspection.
	 *
	 * TOKEN-AND, ORed across columns. A user types "john smith" and expects the customer
	 * called John Smith; a phrase LIKE '%john smith%' against separate first_name and
	 * last_name columns matches nothing at all. So every token must hit at least one
	 * column, and the per-token clauses are ANDed together.
	 *
	 * Each token ALSO gets a phone_digits clause against its own digits-only form, so
	 * "555-0142" finds a phone stored as "(555) 0142". Skipped for a token with no digits
	 * in it, so a name search does not pointlessly scan the phone index.
	 *
	 * @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;
			}

			$digits = preg_replace( '/\D+/', '', (string) $token );

			if ( '' !== (string) $digits ) {
				$parts[] = '%i LIKE %s';
				$args[]  = 'phone_digits';
				$args[]  = '%' . $wpdb->esc_like( (string) $digits ) . '%';
			}

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

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

	/**
	 * Search active customers 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): that method is single-token, four screens depend on its
	 * exact behaviour, and it is a five-branch prepared-SQL tangle. Two small methods with
	 * one SQL shape each cost less than rewriting five statements that already work.
	 *
	 * An empty search returns the plain status-filtered first page, so a picker that has
	 * just been focused fills its dropdown instead of showing "no customers exist".
	 *
	 * @param array $args {
	 *     Optional query arguments.
	 *
	 *     @type string $search   Search term. Whitespace-split, token-ANDed.
	 *     @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: this
		// is the last line before the query, and a future caller is not obliged to clamp.
		$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;
		$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 ( '' !== $where ) {
			$sql       .= ' AND ' . $where;
			$query_args = array_merge( $query_args, $where_args );
		}

		$sql         .= ' ORDER BY last_name ASC, first_name 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 customer matching the same predicate as search_active().
	 *
	 * The WHERE comes from the SAME private builder, because two copies of a search
	 * predicate drift, and a total that disagrees with the rows is a lie on screen
	 * ("showing 20 of N").
	 *
	 * @param array $args Same shape as search_active(); only search affects the count.
	 * @return int Count of matching active customers.
	 */
	public static function count_search_active( array $args = array() ) {
		global $wpdb;

		$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 ( '' !== $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 ) );
	}

	/**
	 * The one "Last, First" display label for a customer row, trimmed of a stray comma.
	 *
	 * Single source, so a search result and a work order header and a calendar event all
	 * render the same customer the same way.
	 *
	 * Accepts null, and returns '' for it, because the caller is usually holding the result
	 * of ::get() on an id that may no longer resolve (an archived or deleted owner). A
	 * label helper that fataled on a missing row would push that check out to every caller.
	 *
	 * @param object|null $customer Customer row, or null when it could not be resolved.
	 * @return string
	 */
	public static function label( $customer ) {
		if ( ! $customer ) {
			return '';
		}

		return trim( sprintf( '%s, %s', $customer->last_name, $customer->first_name ), ', ' );
	}

	/**
	 * Update an existing customer. Recomputes phone_digits and updated_at.
	 *
	 * @param int   $id   Customer id.
	 * @param array $data first_name, last_name, phone, email, address, notes.
	 * @return bool True on success.
	 */
	public static function update( $id, array $data ) {
		global $wpdb;

		$id = absint( $id );

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

		$phone = isset( $data['phone'] ) ? (string) $data['phone'] : '';

		$row = array(
			'first_name'   => isset( $data['first_name'] ) ? (string) $data['first_name'] : '',
			'last_name'    => isset( $data['last_name'] ) ? (string) $data['last_name'] : '',
			'phone'        => $phone,
			'phone_digits' => preg_replace( '/\D+/', '', $phone ),
			'email'        => isset( $data['email'] ) ? (string) $data['email'] : '',
			'address'      => isset( $data['address'] ) ? (string) $data['address'] : '',
			'notes'        => isset( $data['notes'] ) ? (string) $data['notes'] : '',
			'updated_at'   => current_time( 'mysql' ),
		);

		$formats = array( '%s', '%s', '%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 a customer's personal data in place (GDPR erasure, D-06).
	 *
	 * Replaces every identifying field (name, phone, email, address, notes) with
	 * an anonymized placeholder while leaving the row and its status intact, so
	 * the work orders and documents that reference this customer keep their
	 * foreign key and the shop retains its financial/tax history. phone_digits is
	 * cleared alongside phone so the normalized dedup index carries no residue.
	 *
	 * @param int $id Customer id.
	 * @return bool True on success, false on invalid id or DB failure.
	 */
	public static function anonymize( $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(
				'first_name'   => 'anonymized',
				'last_name'    => '',
				'phone'        => '',
				'phone_digits' => '',
				'email'        => '',
				'address'      => '',
				'notes'        => '',
				'updated_at'   => current_time( 'mysql' ),
			),
			array( 'id' => $id ),
			array( '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ),
			array( '%d' )
		);

		return false !== $result;
	}

	/**
	 * Hard-delete a customer row.
	 *
	 * Reserved for the GDPR eraser's discretion branch: a customer with NO
	 * work-order or document history carries no financial record to preserve, so
	 * the row itself may be removed rather than anonymized. Never call this for a
	 * customer that has job history; anonymize() in place instead (D-06).
	 *
	 * @param int $id Customer id.
	 * @return bool True on success, false on invalid id or DB failure.
	 */
	public static function delete( $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.
		$deleted = $wpdb->delete( self::table(), array( 'id' => $id ), array( '%d' ) );

		return false !== $deleted;
	}

	/**
	 * Soft-delete (archive) a customer. The row is retained (D-16); no hard
	 * DELETE is ever issued against the customers table.
	 *
	 * @param int $id Customer 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;
	}
}
