<?php
/**
 * Data-access layer for lead records.
 *
 * Leads are the self-contained submission snapshots captured by the public
 * visit-request form. They live and die in the wrenchpress_leads table and do
 * not touch the customers or vehicles tables until a staff member promotes one.
 * All 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_Leads_Repository
 */
class WrenchPress_Leads_Repository {

	/**
	 * Lifecycle statuses a lead may hold. set_status() rejects anything outside
	 * this enum so an unexpected value never reaches the column. 'archived' is a
	 * terminal state used by the Archive row action; a lead is retained for audit
	 * and can be restored back to 'new'.
	 *
	 * @return string[] Allowed status slugs.
	 */
	public static function statuses() {
		return array( 'new', 'promoted', 'dismissed', 'spam', 'archived' );
	}

	/**
	 * 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',
			'status'     => 'status',
		);
	}

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

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

	/**
	 * Fully qualified customers table name (read-only, for the dedup query).
	 *
	 * @return string
	 */
	protected static function customers_table() {
		global $wpdb;

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

	/**
	 * Insert a new lead from a submission snapshot.
	 *
	 * Computes phone_digits with the same rule as the customers repository so
	 * dedup matches are formatting-insensitive, defaults status to new, and
	 * stamps created_at / updated_at. The caller supplies consent and consent_at
	 * (consent_at only when the box was actually checked), source, and the
	 * submit_ip / submit_ua audit fields. Returns the new row id, or false on
	 * failure.
	 *
	 * @param array $data Submission snapshot fields.
	 * @return int|false New lead 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'] : '';

		$status = isset( $data['status'] ) ? (string) $data['status'] : 'new';
		if ( ! in_array( $status, self::statuses(), true ) ) {
			$status = 'new';
		}

		$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'] : '',
			'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'] : '',
			'subject'              => isset( $data['subject'] ) ? (string) $data['subject'] : '',
			'description'          => isset( $data['description'] ) ? (string) $data['description'] : '',
			'consent'              => ! empty( $data['consent'] ) ? 1 : 0,
			'consent_at'           => isset( $data['consent_at'] ) ? (string) $data['consent_at'] : '0000-00-00 00:00:00',
			'source'               => isset( $data['source'] ) ? (string) $data['source'] : '',
			'submit_ip'            => isset( $data['submit_ip'] ) ? (string) $data['submit_ip'] : '',
			'submit_ua'            => isset( $data['submit_ua'] ) ? (string) $data['submit_ua'] : '',
			'status'               => $status,
			'promoted_customer_id' => 0,
			'created_at'           => $now,
			'updated_at'           => $now,
		);

		$formats = array(
			'%s', // first_name.
			'%s', // last_name.
			'%s', // phone.
			'%s', // phone_digits.
			'%s', // email.
			'%s', // address.
			'%s', // make.
			'%s', // model.
			'%d', // year.
			'%s', // vin.
			'%s', // plate.
			'%d', // mileage.
			'%s', // fuel_type.
			'%s', // transmission.
			'%s', // color.
			'%s', // subject.
			'%s', // description.
			'%d', // consent.
			'%s', // consent_at.
			'%s', // source.
			'%s', // submit_ip.
			'%s', // submit_ua.
			'%s', // status.
			'%d', // promoted_customer_id.
			'%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 lead by id.
	 *
	 * @param int $id Lead 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;
	}

	/**
	 * List leads in a given status, newest first by default.
	 *
	 * @param string $status Lifecycle status to filter by. Default 'new'.
	 * @param array  $args {
	 *     Optional query arguments.
	 *
	 *     @type string $orderby  Request orderby key: 'name', 'created_at', or
	 *                            'status'; anything else falls back to default.
	 *     @type string $order    ASC or DESC. Default 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_by_status( $status = 'new', 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;
		$orderby  = isset( $args['orderby'] ) ? (string) $args['orderby'] : 'created_at';
		$order    = isset( $args['order'] ) ? (string) $args['order'] : 'DESC';

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

		$status = (string) $status;
		if ( ! in_array( $status, self::statuses(), true ) ) {
			$status = 'new';
		}

		// Whitelisted sort columns, passed to prepare() as %i identifiers.
		switch ( $orderby ) {
			case 'name':
				$col1 = 'last_name';
				$col2 = 'first_name';
				break;

			case 'status':
				$col1 = 'status';
				$col2 = 'id';
				break;

			default:
				$col1 = 'created_at';
				$col2 = 'id';
				break;
		}

		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,
					$status,
					$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,
					$status,
					$col1,
					$col2,
					$per_page,
					$offset
				)
			);
		}

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

	/**
	 * Count leads in a given status.
	 *
	 * @param string $status Lifecycle status to filter by. Default 'new'.
	 * @param array  $args   Reserved for parity with list_by_status(); unused.
	 * @return int Count of leads in the status.
	 */
	public static function count_by_status( $status = 'new', array $args = array() ) {
		global $wpdb;

		unset( $args );

		$table = self::table();

		$status = (string) $status;
		if ( ! in_array( $status, self::statuses(), true ) ) {
			$status = 'new';
		}

		// 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, $status )
		);
	}

	/**
	 * Count leads grouped by status, zero-filled for every known slug.
	 *
	 * Runs a single grouped query (status is column data, not request input, so
	 * no binding is needed) and folds the result onto a zero-seeded map of every
	 * statuses() slug, plus an 'all' total. Unknown stored statuses still count
	 * toward 'all' but are not added as new buckets.
	 *
	 * @return array<string, int> Map of 'all' and each status slug to its count.
	 */
	public static function counts_by_status() {
		global $wpdb;

		$counts = array( 'all' => 0 );

		foreach ( self::statuses() as $slug ) {
			$counts[ $slug ] = 0;
		}

		$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 status, COUNT(*) AS c FROM %i GROUP BY status', $table )
		);

		if ( is_array( $rows ) ) {
			foreach ( $rows as $row ) {
				$count          = (int) $row->c;
				$counts['all'] += $count;
				if ( isset( $counts[ $row->status ] ) ) {
					$counts[ $row->status ] = $count;
				}
			}
		}

		return $counts;
	}

	/**
	 * List leads across every status, newest first, with SQL pagination.
	 *
	 * Powers the "All" status view. The query spans all statuses, so there is no
	 * status filter to bind; pagination and ordering are pushed into a single
	 * prepared SELECT. The orderby key is whitelisted to a fixed set of columns
	 * passed to prepare() as %i identifiers; the table, sort columns, and
	 * limit/offset are all bound via placeholders, so no PHP value is
	 * interpolated into the SQL.
	 *
	 * @param array $args {
	 *     Optional query arguments.
	 *
	 *     @type string $orderby  Request orderby key: 'name', 'created_at', or
	 *                            'status'; anything else falls back to default.
	 *     @type string $order    ASC or DESC. Default 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_all( 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;
		$orderby  = isset( $args['orderby'] ) ? (string) $args['orderby'] : 'created_at';
		$order    = isset( $args['order'] ) ? (string) $args['order'] : 'DESC';

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

		// Whitelisted sort columns, passed to prepare() as %i identifiers.
		switch ( $orderby ) {
			case 'name':
				$col1 = 'last_name';
				$col2 = 'first_name';
				break;

			case 'status':
				$col1 = 'status';
				$col2 = 'id';
				break;

			default:
				$col1 = 'created_at';
				$col2 = 'id';
				break;
		}

		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 ORDER BY %i DESC, %i DESC LIMIT %d OFFSET %d',
					$table,
					$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 ORDER BY %i ASC, %i ASC LIMIT %d OFFSET %d',
					$table,
					$col1,
					$col2,
					$per_page,
					$offset
				)
			);
		}

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

	/**
	 * Count every lead regardless of status, for the "All" view total.
	 *
	 * @return int Total lead count.
	 */
	public static function count_all() {
		global $wpdb;

		$table = self::table();

		// 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', $table )
		);
	}

	/**
	 * Archive a lead (set status = 'archived'). The row is retained for audit;
	 * no hard DELETE is ever issued against the leads table. Mirrors the
	 * customers/vehicles archive idiom.
	 *
	 * @param int $id Lead 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;
	}

	/**
	 * Restore an archived lead back to the active inbox (set status = 'new').
	 *
	 * @param int $id Lead id.
	 * @return bool True on success.
	 */
	public static function restore( $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'     => 'new',
				'updated_at' => current_time( 'mysql' ),
			),
			array( 'id' => $id ),
			array( '%s', '%s' ),
			array( '%d' )
		);

		return false !== $result;
	}

	/**
	 * Transition a lead to a new lifecycle status.
	 *
	 * Updates status and updated_at only; the row is never deleted (leads are
	 * retained for audit). Rejects any status outside the lifecycle enum.
	 *
	 * @param int    $id     Lead id.
	 * @param string $status One of new, promoted, dismissed, spam.
	 * @return bool True on success, false on invalid id/status or DB failure.
	 */
	public static function set_status( $id, $status ) {
		global $wpdb;

		$id     = absint( $id );
		$status = (string) $status;

		if ( 0 === $id || ! in_array( $status, self::statuses(), true ) ) {
			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'     => $status,
				'updated_at' => current_time( 'mysql' ),
			),
			array( 'id' => $id ),
			array( '%s', '%s' ),
			array( '%d' )
		);

		return false !== $result;
	}

	/**
	 * Fetch every lead sharing an email, newest first (GDPR export, PRIV-01).
	 *
	 * The email column is non-unique on the leads table, so one person may own
	 * several lead rows across separate visit-form submissions. The personal-data
	 * exporter needs them all, including those of a lead-only person who never
	 * became a customer and therefore holds their entire PII footprint here. A
	 * blank email never matches the stored '' defaults, returning an empty set.
	 *
	 * @param string $email Email whose leads should be returned.
	 * @return array List of matching lead row objects (every status).
	 */
	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 email = %s ORDER BY created_at DESC, id DESC',
				$table,
				$email
			)
		);

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

	/**
	 * Anonymize every identifying field on a person's leads (D-07, PRIV-02).
	 *
	 * Called by the GDPR eraser so no name, contact detail, address, or vehicle
	 * identifier survives on a lead for a person who may never have become a
	 * customer. The rows themselves are RETAINED for the shop's funnel and audit
	 * trail (leads are never deleted); only the identifying columns are blanked,
	 * with first_name set to an 'anonymized' placeholder to mirror the customer
	 * eraser. The spam-defence submit_ip/submit_ua audit fields are cleared in the
	 * same pass, folding in the old IP/UA-only behavior so a single call fully
	 * scrubs the person from the leads table.
	 *
	 * Email is non-unique on the leads table, so one prepared UPDATE matches and
	 * rewrites every row for the address at once; because the match runs before
	 * the email column is overwritten to '', no row is lost mid-update. A blank
	 * email never matches the stored '' defaults.
	 *
	 * @param string $email Email whose leads should be anonymized.
	 * @return int Rows affected (0 on a blank email or when none match).
	 */
	public static function anonymize_pii_for_email( $email ) {
		global $wpdb;

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

		if ( '' === $email ) {
			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(
				'first_name'   => 'anonymized',
				'last_name'    => '',
				'phone'        => '',
				'phone_digits' => '',
				'email'        => '',
				'address'      => '',
				'vin'          => '',
				'plate'        => '',
				'subject'      => '',
				'description'  => '',
				'submit_ip'    => '',
				'submit_ua'    => '',
				'updated_at'   => current_time( 'mysql' ),
			),
			array( 'email' => $email ),
			array( '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ),
			array( '%s' )
		);

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

	/**
	 * Delete unpromoted leads created before an absolute cutoff (PRIV-03).
	 *
	 * Called by the scheduled retention purge. Only leads whose status is not
	 * 'promoted' are removed; a promoted lead is retained because it is tied to a
	 * real customer. The cutoff is an absolute datetime, so re-running after the
	 * window has already been swept deletes nothing further (idempotent). Customers
	 * and work orders are never touched by this method.
	 *
	 * @param string $before MySQL datetime; leads created strictly before this are purged.
	 * @return int Rows deleted (0 on a blank cutoff or when none match).
	 */
	public static function purge_unpromoted_before( $before ) {
		global $wpdb;

		$before = (string) $before;

		if ( '' === $before ) {
			return 0;
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom plugin table maintenance delete, intentionally not object-cached.
		$result = $wpdb->query(
			$wpdb->prepare(
				'DELETE FROM %i WHERE status <> %s AND created_at < %s',
				self::table(),
				'promoted',
				$before
			)
		);

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

	/**
	 * Strip the submitter IP and user-agent from leads created before a cutoff (PRIV-06).
	 *
	 * Called by the scheduled retention purge to remove the raw submit_ip/submit_ua
	 * audit fields from leads older than the configured IP window. The row itself
	 * and all of its other columns are retained. The WHERE guard on the two audit
	 * columns being non-empty makes this idempotent: once a lead has been stripped
	 * it no longer matches, so a re-run over the same window changes nothing.
	 *
	 * @param string $before MySQL datetime; leads created strictly before this are stripped.
	 * @return int Rows updated (0 on a blank cutoff or when none match).
	 */
	public static function strip_ip_before( $before ) {
		global $wpdb;

		$before = (string) $before;

		if ( '' === $before ) {
			return 0;
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom plugin table maintenance update, intentionally not object-cached.
		$result = $wpdb->query(
			$wpdb->prepare(
				'UPDATE %i SET submit_ip = %s, submit_ua = %s, updated_at = %s WHERE ( submit_ip <> %s OR submit_ua <> %s ) AND created_at < %s',
				self::table(),
				'',
				'',
				current_time( 'mysql' ),
				'',
				'',
				$before
			)
		);

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

	/**
	 * Flag a lead as promoted and record the customer it was linked to.
	 *
	 * Used by the promotion flow once a lead has been turned into (or linked to)
	 * a customer. The row is retained for audit (D-07).
	 *
	 * @param int $id          Lead id.
	 * @param int $customer_id Customer the lead was promoted into.
	 * @return bool True on success.
	 */
	public static function set_promoted( $id, $customer_id ) {
		global $wpdb;

		$id          = absint( $id );
		$customer_id = absint( $customer_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'               => 'promoted',
				'promoted_customer_id' => $customer_id,
				'updated_at'           => current_time( 'mysql' ),
			),
			array( 'id' => $id ),
			array( '%s', '%d', '%s' ),
			array( '%d' )
		);

		return false !== $result;
	}

	/**
	 * Find active customers that match a lead's phone or email (CUST-03 dedup).
	 *
	 * Normalizes the phone with the same rule as the customers repository, then
	 * matches active customers on normalized phone_digits OR exact email. Empty
	 * values never match (the <> '' guards). Used by the promotion interstitial
	 * to warn staff about a likely duplicate before creating a new customer.
	 *
	 * @param string $phone Raw phone from the lead snapshot.
	 * @param string $email Email from the lead snapshot.
	 * @return array List of matching active customer row objects.
	 */
	public static function find_matching_customers( $phone, $email ) {
		global $wpdb;

		$phone_digits = preg_replace( '/\D+/', '', (string) $phone );
		$email        = (string) $email;

		$customers = self::customers_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 ( ( phone_digits <> %s AND phone_digits = %s ) OR ( email <> %s AND email = %s ) )',
				$customers,
				'active',
				'',
				$phone_digits,
				'',
				$email
			)
		);

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