<?php
/**
 * Contact model
 *
 * @link       https://personalizewp.com/
 * @since      3.2.0 Merged into PWP
 * @since      1.0.0
 *
 * @package    PersonalizeWP
 * @subpackage PersonalizeWP/Model
 */

namespace PersonalizeWP\Model;

defined( 'ABSPATH' ) || exit; // Exit if accessed directly.

/**
 * Contact model
 *
 * This class defines Contact structure, and has a selection of static utility functions.
 */
class Contact extends Model {

	/**
	 * Extended profile fields, stored as meta
	 *
	 * @since 2.0.0
	 * @var array
	 */
	public $extended_fields = [];

	/**
	 * Profile fields to anonymise.
	 *
	 * @since 2.8.0
	 * @var array
	 */
	protected $anonymised_fields = [];

	/**
	 * Should fields be filtered before saving.
	 *
	 * @since 2.8.0
	 * @var bool
	 */
	protected $anonymise_mode = false;

	/**
	 * Database table name storing Contacts
	 *
	 * @var string
	 */
	protected static string $table = 'pwp_contacts';

	/**
	 * Matches to database columns, excluding ID
	 *
	 * @var string[]
	 */
	protected static $attributes = [
		'uid',
		'first_name',
		'last_name',
		'email',
		'lead_score',
		'created',
		'last_seen',
		'is_known',
		'is_verified',
	];

	/**
	 * Constructor
	 *
	 * @since 2.0.0
	 * @param array $data Data to validate against attributes
	 */
	public function __construct( $data ) {
		parent::__construct( $data );

		$this->anonymise_mode = personalizewp()->should_anonymise_profiles();

		$base_extended_fields = array(
			'phone_number'    => __( 'Phone Number', 'personalizewp' ),
			'address_street'  => __( 'Street Address', 'personalizewp' ),
			'address_line_2'  => __( 'Address Line 2', 'personalizewp' ),
			'address_city'    => __( 'City', 'personalizewp' ),
			'address_state'   => __( 'State / Province / Region', 'personalizewp' ),
			'address_zipcode' => __( 'Zip / Postal Code', 'personalizewp' ),
			'address_country' => __( 'Country', 'personalizewp' ),
			'website'         => __( 'Website', 'personalizewp' ),
		);

		/**
		 * Filters the registered extended profile fields mapping to meta data.
		 *
		 * @since 2.0.0
		 * @param string[] $fields   An array of meta fields to labels.
		 */
		$this->extended_fields = apply_filters(
			'personalizewp_profile_extended_fields',
			$base_extended_fields
		);

		// Separately store the built in fields for later anonymise checks during meta storage.
		$this->anonymised_fields = array_merge(
			array_keys( $base_extended_fields ),
			array(
				'marketing_consent',
				'privacy_policy',
				'wp_user_id',
				'wp_user_role',
			)
		);
	}

	/**
	 * Populate the Contact caches
	 *
	 * @param Contact|array $contact Contact to cache
	 *
	 * @return void
	 */
	protected static function update_caches( $contact ) {

		if ( ! is_array( $contact ) ) {
			// Convert from object to array of data, with ID.
			$contact = array_merge(
				array(
					'ID' => $contact->ID,
				),
				$contact->data
			);
		}
		if ( empty( $contact['ID'] ) || $contact['uid'] ) {
			return;
		}

		wp_cache_set( $contact['ID'], $contact, 'pwp_pro_contacts' );
		wp_cache_set( $contact['uid'], $contact['ID'], 'pwp_pro_contacts-uid' );
	}

	/**
	 * Retrieve Visitor/Contact instance.
	 *
	 * @since 3.4.0
	 *
	 * @global wpdb $wpdb WordPress database abstraction object.
	 *
	 * @param int $contact_id Contact ID.
	 * @return Contact|null Contact object, null otherwise.
	 */
	public static function get_instance( $contact_id ) {
		global $wpdb;

		$contact_id = (int) $contact_id;
		if ( $contact_id <= 0 ) {
			return null;
		}

		$_contact = wp_cache_get( $contact_id, 'pwp_pro_contacts' );
		if ( ! $_contact ) {
			$_contact = $wpdb->get_row(
				$wpdb->prepare(
					'SELECT * FROM %i WHERE `id` = %d LIMIT 1',
					$wpdb->prefix . self::$table,
					$contact_id
				),
				ARRAY_A
			);

			if ( ! $_contact ) {
				return null;
			}
			$_contact = sanitize_post( $_contact, 'raw' );

			wp_cache_set( $_contact['ID'], $_contact, 'pwp_pro_contacts' );
		}

		return new Contact( $_contact );
	}

	/**
	 * Retrieve Contact by their unique ID.
	 * Optionally updates the Contact if it alreadys exists and data provided; or creates the Contact if it doesn't already exist.
	 *
	 * @since 3.4.0 Refactored version of `findByUid` to flip default to not creating users.
	 *
	 * @global wpdb $wpdb WordPress database abstraction object.
	 *
	 * @param string $unique_id    Browser unique ID
	 * @param array  $contact_arr  Visitor data to insert/update, optional {
	 *     @type string $first_name  First Name of the Visitor
	 *     @type string $last_name   Last Name of the Visitor
	 *     @type string $email       Email address of the Visitor
	 *     @type int    $lead_score  The visitor lead score. Default 0.
	 *     @type bool   $is_verified Flag to indicate the Visitor data has been verified
	 *     @type array  $meta_input  Key value pairs of meta data to set for the Visitor
	 * }
	 *
	 * @return Contact|null Instance of Contact or null on failure
	 */
	public static function find_by_uid( string $unique_id, $contact_arr = null ) {
		global $wpdb;

		// Via cache convert uid to ID
		$unique_id   = sanitize_text_field( $unique_id );
		$_contact_id = wp_cache_get( $unique_id, 'pwp_pro_contacts-uid' );
		if ( ! $_contact_id ) {
			// Get ID from uid
			$_contact_id = $wpdb->get_var(
				$wpdb->prepare(
					'SELECT ID FROM %i WHERE `uid` = %s LIMIT 1',
					$wpdb->prefix . self::$table,
					$unique_id
				)
			);

			if ( $_contact_id ) {
				wp_cache_set( $unique_id, $_contact_id, 'pwp_pro_contacts-uid' );
			}
		}

		$_contact = self::get_instance( $_contact_id );
		if ( $_contact ) {
			if ( is_array( $contact_arr ) && ! empty( $contact_arr ) ) {

				// Ensure ID as updating contact.
				$contact_arr['ID'] = $_contact->ID;

				self::update_contact( $contact_arr );
			}

			return $_contact;
		}

		// Have no Contact, check for creation logic.
		$create = is_array( $contact_arr );
		if ( ! $create ) {
			return null;
		}

		// Ensure UID part of data to insert.
		$contact_arr['uid'] = $unique_id;

		$contact_id = self::insert_contact( $contact_arr );
		if ( $contact_id ) {
			return self::get_instance( $contact_id );
		}

		return null;
	}

	/**
	 * Inserts or updates a visitor/contact with personal data (name, email etc).
	 *
	 * @since 3.4.0
	 *
	 * @global wpdb $wpdb WordPress database abstraction object.
	 *
	 * @param array $contact_arr {
	 *     An array of elements that make up a visitor/contact to update or insert.
	 *
	 *     @type string $uid         The visitor/contact universal unique ID. Required.
	 *     @type int    $ID          The contact ID. If equal to something other than 0,
	 *                               the contact with that ID will be updated. Default 0.
	 *     @type string $first_name  The visitor first name. Default empty.
	 *     @type string $last_name   The visitor last name. Default empty.
	 *     @type string $email       The visitor email address. Default empty.
	 *     @type int    $lead_score  The visitor lead score. Default 0.
	 *     @type bool   $is_verified The visitor verification flag. Default 0.
	 *     @type array  $meta_input  Array of visitor meta values keyed by their visitor meta key. Default empty.
	 * }
	 * @param bool  $wp_error        Optional. Whether to return a WP_Error on failure. Default false.
	 *
	 * @return int|WP_Error The visitor ID on success. The value 0 or WP_Error on failure.
	 */
	public static function insert_contact( array $contact_arr, $wp_error = false ) {
		global $wpdb;

		// Updating a lead score triggers a custom action.
		$updating_lead_score = isset( $contact_arr['lead_score'] );

		$right_now = current_time( 'mysql' );
		$defaults  = array(
			'uid'         => '',
			'first_name'  => '',
			'last_name'   => '',
			'email'       => '',
			'lead_score'  => 0,
			'created'     => '',
			'last_seen'   => '',
			'is_known'    => 0,
			'is_verified' => 0,
		);

		$contact_arr = wp_parse_args( $contact_arr, $defaults );
		unset( $contact_arr['filter'] );

		$contact_arr = sanitize_post( $contact_arr, 'db' );

		// Check for required UID value.
		if ( empty( $contact_arr['uid'] ) ) {
			return 0;
		}

		// Are we updating or creating?
		$contact_id = 0;
		$update     = false;

		if ( ! empty( $contact_arr['ID'] ) ) {
			$update = true;

			// Get the contact ID.
			$contact_id     = $contact_arr['ID'];
			$contact_before = self::get_instance( $contact_id );

			if ( is_null( $contact_before ) ) {
				if ( $wp_error ) {
					return new \WP_Error( 'pwp_invalid_contact', __( 'Invalid visitor ID.', 'personalizewp' ) );
				}
				return 0;
			}
		} else {
			$contact_before = null;
			// Inserting is always 'updating' the lead score.
			$updating_lead_score = true;
		}

		$uid        = $contact_arr['uid'];
		$first_name = $contact_arr['first_name'];
		$last_name  = $contact_arr['last_name'];
		$email      = $contact_arr['email'];
		$lead_score = (int) $contact_arr['lead_score'];

		// Calculate known logic based on email and partial name, defaulting to existing Contact.
		$is_known = ! is_null( $contact_before ) ? $contact_before->is_known : false;
		if ( ! empty( $email ) &&
			( ! empty( $first_name ) || ! empty( $last_name ) )
		) {
			// Clearly updating personal data, so must now be "known".
			$is_known = true;
		}

		// Note: Contacts can be verified on first creation.
		$is_verified = ! empty( $contact_arr['is_verified'] );

		// Centrally check for opt-in preference to track anonymous visitors or not.
		// Visitor/Contact is only created if known, or anonymous tracking is turned on. Default off.
		$track_anonymous = personalizewp()->should_track_anonymous_users();
		if ( ! $is_known && ! $track_anonymous ) {
			if ( $wp_error ) {
				return new \WP_Error( 'pwp_anonymous_visitor', __( 'Visitor could not be created as unknown or anonymous.', 'personalizewp' ) );
			} else {
				return 0;
			}
		}

		// Centrally check for opt-in preference to anonymise Visitor/Contact updates or not.
		// Visitor/Contact is still created/updated either way. Default off.
		// Note: Existing PII data isn't cleared.
		$anonymise_profiles = personalizewp()->should_anonymise_profiles();
		if ( $anonymise_profiles ) {
			if ( is_null( $contact_before ) && ! $is_verified ) {
				// Honour the preference by clearing some fields, unless verified as a WP User.
				// Note: Primarily only for new Contacts.
				$first_name = '';
				$last_name  = '';
				$email      = '';
			} elseif ( ! is_null( $contact_before ) && ! ( $contact_before->is_verified  || $is_verified ) ) {
				// Revert to existing Contact data, if it exists, even if ends up blank.
				// Note: Only if the Contact is not already verified or will be verified. Thus allowing verified updates.
				$first_name = $contact_before->first_name;
				$last_name  = $contact_before->last_name;
				$email      = $contact_before->email;
			}
		}

		// Only new to insert Visitors will have no created date.
		if ( empty( $contact_arr['created'] ) ) {
			$created = $right_now;
		} else {
			$created = $contact_arr['created'];
		}
		// Ensure up to date last seen time, even if last seen is already set.
		if ( $update || empty( $contact_arr['last_seen'] ) ) {
			$last_seen = $right_now;
		} else {
			$last_seen = $contact_arr['last_seen'];
		}

		$data = compact(
			'uid',
			'first_name',
			'last_name',
			'email',
			'lead_score',
			'is_known',
			'is_verified',
			'created',
			'last_seen',
		);

		$table = static::$table;
		$where = array( 'ID' => $contact_id );
		if ( $update ) {

			if ( false === $wpdb->update( "{$wpdb->prefix}{$table}", $data, $where ) ) {
				if ( $wp_error ) {
					return new \WP_Error( 'pwp_db_update_error', __( 'Could not update visitor in the database.', 'personalizewp' ), $wpdb->last_error );
				} else {
					return 0;
				}
			}
			// Force overwrite of existing cache data, with ID.
			$data['ID'] = $contact_id;
			wp_cache_set( $contact_id, $data, 'pwp_pro_contacts' );

		} else {

			if ( false === $wpdb->insert( "{$wpdb->prefix}{$table}", $data ) ) {
				if ( $wp_error ) {
					return new \WP_Error( 'pwp_db_insert_error', __( 'Could not insert visitor into the database.', 'personalizewp' ) );
				} else {
					return 0;
				}
			}
			// No need to set cache, that'll happen below with get_instance()
			$contact_id = (int) $wpdb->insert_id;
		}

		$contact = self::get_instance( $contact_id );

		if ( is_null( $contact ) ) {
			if ( $wp_error ) {
				return new \WP_Error( 'pwp_invalid_contact', __( 'Invalid visitor ID.', 'personalizewp' ) );
			}
			return 0;
		}

		if ( $update ) {
			/**
			 * Fires once a Contact has been updated.
			 *
			 * @param Contact $contact  Contact object.
			 */
			do_action( 'personalizewp_contact_update', $contact );

		} else {
			/**
			 * Fires once a new Contact has been saved.
			 *
			 * @param Contact $contact  Contact object.
			 */
			do_action( 'personalizewp_contact_insert', $contact );

		}

		if ( $updating_lead_score ) {
			/**
			 * Fires when a Contact' Lead Score has been updated.
			 *
			 * @since 1.1.0
			 *
			 * @param Contact $contact  Contact object.
			 */
			do_action( 'personalizewp_contact_update_score', $contact );
		}

		if ( ! empty( $contact_arr['meta_input'] ) ) {
			foreach ( $contact_arr['meta_input'] as $field => $value ) {
				$contact->update_metadata( $field, $value );
			}
		}

		return $contact_id;
	}

	/**
	 * Update a visitor/contact with personal data (name, email etc).
	 *
	 * @since 3.4.0
	 *
	 * @param array|object $contact_arr {
	 *     An array of elements that make up the changed fields on a visitor/contact to update. Or the Visitor itself.
	 *
	 *     @type int    $ID          The contact ID. Required.
	 *     @type string $uid         The visitor/contact universal unique ID. Optional.
	 *     @type string $first_name  The visitor first name. Default empty.
	 *     @type string $last_name   The visitor last name. Default empty.
	 *     @type string $email       The visitor email address. Default empty.
	 *     @type string $lead_score  The visitor lead score adjustment, to be applied to the existing lead score. Only when passed in array.
	 *     @type bool   $is_verified The visitor verification flag. Default 0.
	 *     @type bool   $is_known    The visitor is known flag. Default 0.
	 *     @type array  $meta_input  Array of visitor meta values keyed by their visitor meta key. Default empty.
	 * }
	 * @param bool         $wp_error     Optional. Whether to return a WP_Error on failure. Default false.
	 *
	 * @return int|WP_Error  The visitor ID on success. The value 0 or WP_Error on failure.
	 */
	public static function update_contact( $contact_arr = array(), $wp_error = false ) {

		// Check for incoming verification, force update of data then?

		// If passed as an array, track required adjustment of lead score to be applied to the existing contact.
		$modify_lead_score = ( is_array( $contact_arr ) && isset( $contact_arr['lead_score'] ) ) ? (int) $contact_arr['lead_score'] : null;

		// Convert the Contact object to an array for easier logic.
		if ( is_object( $contact_arr ) ) {
			// Convert object to array to use as updates data.
			$contact_arr = array_merge(
				array(
					'ID' => $contact_arr->ID,
				),
				$contact_arr->data
			);
		}

		// First, get all of the original fields.
		$contact = self::get_instance( $contact_arr['ID'] );

		if ( is_null( $contact ) ) {
			if ( $wp_error ) {
				return new \WP_Error( 'pwp_invalid_visitor', __( 'Invalid visitor ID.', 'personalizewp' ) );
			}
			return 0;
		}

		// If verified Contact already, don't allow the PII data to change unless incoming changes also marked as verified.
		if ( $contact->is_verified && empty( $contact_arr['is_verified'] ) ) {
			// Remove the incoming PII changes, whatever they may be.
			unset( $contact_arr['first_name'], $contact_arr['last_name'], $contact_arr['email'] );
		}

		// A lead score update is applied to the Contacts existing lead score.
		if ( ! is_null( $modify_lead_score ) && 0 !== (int) $modify_lead_score ) {
			// Supports negative adjustment as well as positive.
			$contact_arr['lead_score'] = $contact->lead_score + $modify_lead_score;
		}

		// Created date is never meant to be updatable.
		// Remove here and use the existing object date.
		unset( $contact_arr['created'] );

		// As updating, ensure ID.
		$contact_arr['ID'] = $contact->ID;

		// Merge required changes with all the existing object data, which acts as defaults.
		$contact_arr = wp_parse_args(
			$contact_arr,
			$contact->data
		);

		// Last seen date is also never meant to be directly modified.
		// Remove here, and allow insertion to set.
		unset( $contact_arr['last_seen'] );

		return self::insert_contact( $contact_arr );
	}

	/**
	 * Update a contact with new personal data (name, email etc).
	 *
	 * @param array $contact_arr  Contact data to update, default use the current object vars.
	 * @param bool  $force        Whether to force the update of personalised data or not, as user verified
	 *
	 * @return bool|integer Database ID on success, false otherwise
	 */
	public function update( array $contact_arr = [], $force = false ) {
		_deprecated_function( __FUNCTION__, '3.4.0', 'Contact::update_contact()' );

		$contact_arr = array_merge(
			array(
				'ID'          => $this->ID,
				'is_verified' => $force, // This could force an update.
			),
			$contact_arr,
			$this->data
		);

		return self::update_contact( $contact_arr );
	}

	/**
	 * Update the lead score of a Contact.
	 * Separate to the main update() as doesn't use a verified check.
	 *
	 * @since 1.1.0
	 *
	 * @param int $adjustment Lead Score adjustment, can be positive or negative
	 *
	 * @return bool True on change, false otherwise
	 */
	public function update_lead_score( int $adjustment ) {

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

		// Supports negative adjustment as well as positive.
		$this->lead_score = $this->lead_score + $adjustment;

		return self::update_contact( $this );
	}

	/**
	 * Save Contact to database. TO BE DELETED?
	 *
	 * @since 3.4.0
	 *
	 * @return bool|integer Database ID on success, false otherwise
	 */
	public function save() {
		_deprecated_function( __FUNCTION__, '3.4.0', 'Contact::update_contact()' );

		return self::update_contact( $this );
	}

	/**
	 * Gets the segments the Contact is in.
	 *
	 * @return array
	 */
	public function getSegmentsAttribute() {
		return Segment::get_contact_segments( $this->ID );
	}

	/**
	 * Retrieves profile meta field for a Profile.
	 *
	 * @since 2.0.0
	 *
	 * @param string $meta_key Optional. The meta key to retrieve. By default,
	 *                         returns data for all keys.
	 * @param bool   $single   Optional. Whether to return a single value.
	 *                         This parameter has no effect if `$key` is not specified.
	 *                         Default false.
	 * @return mixed An array of values if `$single` is false.
	 *               The value of meta data field if `$single` is true.
	 */
	public function get_metadata( $meta_key = '', $single = false ) {
		$meta_cache = wp_cache_get( $this->ID, 'pwp-profiles_meta' );

		if ( ! $meta_cache ) {
			$meta_cache = self::update_meta_cache( array( $this->ID ) );
			if ( isset( $meta_cache[ $this->ID ] ) ) {
				$meta_cache = $meta_cache[ $this->ID ];
			} else {
				$meta_cache = null;
			}
		}

		if ( ! $meta_key ) {
			return $meta_cache;
		}

		if ( isset( $meta_cache[ $meta_key ] ) ) {
			if ( $single ) {
				return maybe_unserialize( $meta_cache[ $meta_key ][0] );
			} else {
				return array_map( 'maybe_unserialize', $meta_cache[ $meta_key ] );
			}
		}

		return null;
	}

	/**
	 * Adds metadata for a Profile.
	 *
	 * @since 2.0.0
	 *
	 * @global wpdb $wpdb WordPress database abstraction object.
	 *
	 * @param string $meta_key   Metadata key.
	 * @param mixed  $meta_value Metadata value. Must be serializable if non-scalar.
	 * @param bool   $unique     Optional. Whether the specified metadata key should be unique for the object.
	 *                           If true, and the object already has a value for the specified metadata key,
	 *                           no change will be made. Default false.
	 * @return int|false The meta ID on success, false on failure.
	 */
	public function add_metadata( $meta_key, $meta_value, $unique = false ) {
		global $wpdb;

		if ( ! $meta_key ||
			// Skip fields if in anonymised mode.
			( $this->anonymise_mode && ! empty( $this->anonymised_fields[ $meta_key ] ) )
		) {
			return false;
		}

		// Note: Expects slashed meta_key
		$meta_key   = wp_unslash( $meta_key );
		$meta_value = wp_unslash( $meta_value );
		$meta_value = sanitize_meta( $meta_key, $meta_value, 'user' );

		if ( $unique && $wpdb->get_var(
			$wpdb->prepare(
				'SELECT COUNT(*) FROM %i WHERE meta_key = %s AND contact_id = %d',
				$wpdb->prefix . self::$table . '_meta',
				$meta_key,
				$this->ID
			)
		) ) {
			return false;
		}

		$_meta_value = $meta_value;
		$meta_value  = maybe_serialize( $meta_value );

		/**
		 * Fires immediately before Contact meta is added.
		 *
		 * @since 3.3.0
		 *
		 * @param Contact $contact     Contact object.
		 * @param string  $meta_key    Metadata key.
		 * @param mixed   $_meta_value Metadata value.
		 */
		do_action( 'personalizewp_add_contact_meta', $this, $meta_key, $_meta_value );

		$result = $wpdb->insert(
			$wpdb->prefix . self::$table . '_meta',
			// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Reason: Inserting contact
			array(
				'contact_id' => $this->ID,
				'meta_key'   => $meta_key,
				'meta_value' => $meta_value,
			)
			// phpcs:enable
		);

		if ( ! $result ) {
			return false;
		}

		$mid = (int) $wpdb->insert_id;

		wp_cache_delete( $this->ID, 'pwp-profiles_meta' );

		/**
		 * Fires immediately after Contact meta is added.
		 *
		 * @since 3.3.0
		 *
		 * @param int     $mid         The meta ID after successful update.
		 * @param Contact $contact     Contact object.
		 * @param string  $meta_key    Metadata key.
		 * @param mixed   $_meta_value Metadata value.
		 */
		do_action( 'personalizewp_added_contact_meta', $mid, $this, $meta_key, $_meta_value );

		return $mid;
	}

	/**
	 * Updates metadata for the current profile. If no value already exists for the specified
	 * metadata key, the metadata will be added.
	 *
	 * @since 2.0.0
	 *
	 * @global wpdb $wpdb WordPress database abstraction object.
	 *
	 * @param string $meta_key   Metadata key.
	 * @param mixed  $meta_value Metadata value. Must be serializable if non-scalar.
	 * @return int|bool The new meta field ID if a field with the given key didn't exist
	 *                  and was therefore added, true on successful update,
	 *                  false on failure or if the value passed to the function
	 *                  is the same as the one that is already in the database.
	 */
	public function update_metadata( $meta_key, $meta_value ) {
		global $wpdb;

		if ( ! $meta_key ||
			// Skip fields if in anonymised mode.
			( $this->anonymise_mode && ! empty( $this->anonymised_fields[ $meta_key ] ) )
		) {
			return false;
		}

		// Note: expects slashed meta_key
		$raw_meta_key = $meta_key;
		$meta_key     = wp_unslash( $meta_key );
		$passed_value = $meta_value;
		$meta_value   = wp_unslash( $meta_value );
		$meta_value   = sanitize_meta( $meta_key, $meta_value, 'user' );

		$meta_ids = $wpdb->get_col(
			$wpdb->prepare(
				'SELECT meta_id FROM %i WHERE meta_key = %s AND contact_id = %d',
				$wpdb->prefix . self::$table . '_meta',
				$meta_key,
				$this->ID
			)
		);
		if ( empty( $meta_ids ) ) {
			return $this->add_metadata( $raw_meta_key, $passed_value );
		}

		$_meta_value = $meta_value;
		$meta_value  = maybe_serialize( $meta_value );

		$data  = compact( 'meta_value' );
		$where = array(
			'contact_id' => $this->ID,
			// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Reason: updating contact
			'meta_key'   => $meta_key,
			// phpcs:enable
		);

		foreach ( $meta_ids as $meta_id ) {
			/**
			 * Fires immediately before updating Contact metadata.
			 *
			 * @since 3.3.0
			 *
			 * @param int     $meta_id     ID of the metadata entry to update.
			 * @param Contact $contact     Contact object.
			 * @param string  $meta_key    Metadata key.
			 * @param mixed   $_meta_value Metadata value.
			 */
			do_action( 'personalizewp_update_contact_meta', $meta_id, $this, $meta_key, $_meta_value );
		}

		$result = $wpdb->update( $wpdb->prefix . self::$table . '_meta', $data, $where );
		if ( ! $result ) {
			return false;
		}

		wp_cache_delete( $this->ID, 'pwp-profiles_meta' );

		foreach ( $meta_ids as $meta_id ) {
			/**
			 * Fires immediately after updating Contact metadata.
			 *
			 * @since 3.3.0
			 *
			 * @param int     $meta_id     ID of updated metadata entry.
			 * @param Contact $contact     Contact object.
			 * @param string  $meta_key    Metadata key.
			 * @param mixed   $_meta_value Metadata value.
			 */
			do_action( 'personalizewp_updated_contact_meta', $meta_id, $this, $meta_key, $_meta_value );
		}

		return true;
	}

	/**
	 * Deletes metadata for the current Profile.
	 *
	 * @since 2.0.0
	 *
	 * @global wpdb $wpdb WordPress database abstraction object.
	 *
	 * @param string $meta_key   Metadata key.
	 * @param mixed  $meta_value Optional. Metadata value. Must be serializable if non-scalar.
	 *                           If specified, only delete metadata entries with this value.
	 *                           Otherwise, delete all entries with the specified meta_key.
	 *                           Pass `null`, `false`, or an empty string to skip this check.
	 *                           (For backward compatibility, it is not possible to pass an empty string
	 *                           to delete those entries with an empty string for a value.)
	 *                           Default empty string.
	 * @return bool True on successful delete, false on failure.
	 */
	public function delete_metadata( $meta_key, $meta_value = '' ) {
		global $wpdb;

		if ( ! $meta_key ) {
			return false;
		}

		// Note: expects slashed meta_key
		$meta_key   = wp_unslash( $meta_key );
		$meta_value = wp_unslash( $meta_value );

		$_meta_value = $meta_value;
		$meta_value  = maybe_serialize( $meta_value );

		$query = $wpdb->prepare(
			'SELECT meta_id FROM %i WHERE meta_key = %s AND contact_id = %d',
			$wpdb->prefix . self::$table . '_meta',
			$meta_key,
			$this->ID
		);

		if ( '' !== $meta_value && null !== $meta_value && false !== $meta_value ) {
			$query .= $wpdb->prepare( ' AND meta_value = %s', $meta_value );
		}

		$meta_ids = $wpdb->get_col( $query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Reason, already prepared
		if ( ! count( $meta_ids ) ) {
			return false;
		}

		/**
		 * Fires immediately before deleting Contact metadata.
		 *
		 * @since 3.3.0
		 *
		 * @param string[] $meta_ids    An array of metadata entry IDs to delete.
		 * @param Contact  $contact     Contact object.
		 * @param string   $meta_key    Metadata key.
		 * @param mixed    $_meta_value Metadata value.
		 */
		do_action( 'personalizewp_delete_contact_meta', $meta_ids, $this, $meta_key, $_meta_value );

		$id_placeholders = implode( ', ', array_fill( 0, count( $meta_ids ), '%d' ) );
		$count           = $wpdb->query(
			$wpdb->prepare(
				"DELETE FROM %i WHERE meta_id IN( {$id_placeholders} )", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Reason, using placeholders
				array_merge( array( $wpdb->prefix . self::$table . '_meta' ), $meta_ids )
			)
		);

		if ( ! $count ) {
			return false;
		}

		$data = array( $this->ID );
		wp_cache_delete_multiple( $data, 'pwp-profiles_meta' );

		/**
		 * Fires immediately after deleting Contact metadata.
		 *
		 * @since 3.3.0
		 *
		 * @param string[] $meta_ids    An array of metadata entry IDs to delete.
		 * @param Contact  $contact     Contact object.
		 * @param string   $meta_key    Metadata key.
		 * @param mixed    $_meta_value Metadata value.
		 */
		do_action( 'personalizewp_deleted_contact_meta', $meta_ids, $this, $meta_key, $_meta_value );

		return true;
	}

	/**
	 * Determines if a meta field with the given key exists for this profile.
	 *
	 * @since 2.0.0
	 *
	 * @param string $meta_key  Metadata key.
	 * @return bool Whether a meta field with the given key exists.
	 */
	public function metadata_exists( $meta_key ) {
		$meta_cache = wp_cache_get( $this->ID, 'pwp-profiles_meta' );

		if ( ! $meta_cache ) {
			$meta_cache = self::update_meta_cache( array( $this->ID ) );
			$meta_cache = $meta_cache[ $this->ID ];
		}

		if ( isset( $meta_cache[ $meta_key ] ) ) {
			return true;
		}

		return false;
	}

	/**
	 * Retrieve Contact by their unique ID. Will optionally create the Contact if it doesn't already exist.
	 *
	 * @param  string $unique_id  Browser unique ID
	 * @param  bool   $create     Create Contact if it doesn't already exist, defaults true
	 *
	 * @return Contact|false Instance of Contact or false on failure
	 */
	public static function findByUid( string $unique_id, $create = true ) {
		_deprecated_function( __FUNCTION__, '3.4.0', 'find_by_uid()' );

		$user_data = is_array( $create ) ? $create : array( 'email' => '' );

		return self::find_by_uid( $unique_id, $user_data );
	}

	/**
	 * Deletes Contact, it's meta data and related Activities
	 *
	 * @param integer $id ID of database row
	 *
	 * @return bool True on success, false on failure
	 */
	public static function delete( int $id ) {
		global $wpdb;

		$contact = self::get_instance( $id );
		if ( is_null( $contact ) ) {
			// Nothing to do.
			return;
		}

		/**
		 * Filters whether a Contact deletion should take place.
		 *
		 * @since 2.3.0
		 *
		 * @param false|null $delete  Whether to go forward with deletion.
		 * @param Contact    $contact Contact object.
		 */
		$check = apply_filters( 'personalizewp_pre_delete_contact', null, $contact );
		if ( null !== $check ) {
			return $check;
		}

		/**
		 * Fires before a Contact is deleted, at the start of Contact::delete().
		 *
		 * @since 2.3.0
		 *
		 * @param Contact $contact Contact object.
		 */
		do_action( 'personalizewp_before_delete_contact', $contact );

		$segments = Segment::get_contact_segments( $contact->ID );
		// Loop through each Segment the Visitor may be in remove relationship.
		foreach ( $segments as $segment ) {
			Segment::remove_contact( $segment->ID, $contact->ID );
		}

		$result = Activity::delete_by_contact_id( $contact->ID );

		// Run the query, will return true if deleted, false otherwise.
		$result = (bool) $wpdb->delete( $wpdb->prefix . self::$table . '_meta', array( 'contact_id' => $contact->ID ) );

		// Clear the meta user cache.
		wp_cache_delete_multiple( array( $contact->ID ), 'pwp-profiles_meta' );

		// Clear the normal cache.
		wp_cache_delete( $contact->ID, 'pwp_pro_contacts' );
		wp_cache_delete( $contact->uid, 'pwp_pro_contacts-uid' );

		// Run the query, will return true if deleted, false otherwise.
		return (bool) $wpdb->delete( $wpdb->prefix . self::$table, [ 'ID' => $contact->ID ], [ '%d' ] );
	}

	/**
	 * Retrieve Contact instance via ID
	 *
	 * @since 1.1.0 Added to ensure correct return for docs.
	 *
	 * @param  integer $id ID of database row
	 *
	 * @return Contact|false Instance of Contact on success, false on failure
	 */
	public static function find( $id ) { // phpcs:ignore Generic.CodeAnalysis.UselessOverridingMethod.Found -- Reason: adjusting return type in docs
		_deprecated_function( __FUNCTION__, '3.4.0', 'get_instance()' );
		return self::get_instance( $id );
	}

	/**
	 * Updates the metadata cache for the specified Profiles.
	 *
	 * @since 2.0.0
	 *
	 * @param string|int[] $ids Array or comma delimited list of IDs to update cache for.
	 * @return array|false Metadata cache for the specified profiles, or false on failure.
	 */
	public static function update_meta_cache( $ids ) {
		global $wpdb;

		if ( ! $ids ) {
			return false;
		}

		if ( ! is_array( $ids ) ) {
			$ids = preg_replace( '|[^0-9,]|', '', $ids );
			$ids = explode( ',', $ids );
		}

		$ids = array_map( 'intval', $ids );

		$cache_key      = 'pwp-profiles_meta';
		$non_cached_ids = array();
		$cache          = array();
		$cache_values   = wp_cache_get_multiple( $ids, $cache_key );

		foreach ( $cache_values as $id => $cached_object ) {
			if ( false === $cached_object ) {
				$non_cached_ids[] = $id;
			} else {
				$cache[ $id ] = $cached_object;
			}
		}

		if ( empty( $non_cached_ids ) ) {
			return $cache;
		}

		// Get meta info.
		$id_placeholders = implode( ', ', array_fill( 0, count( $non_cached_ids ), '%d' ) );
		$meta_list       = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT contact_id, meta_key, meta_value FROM %i WHERE contact_id IN ( {$id_placeholders} ) ORDER BY meta_id ASC", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Reason, using placeholders
				array_merge( array( $wpdb->prefix . self::$table . '_meta' ), $non_cached_ids )
			),
			ARRAY_A
		);

		if ( ! empty( $meta_list ) ) {
			foreach ( $meta_list as $metarow ) {
				$mpid = (int) $metarow['contact_id'];
				$mkey = $metarow['meta_key'];
				$mval = $metarow['meta_value'];

				// Force subkeys to be array type.
				if ( ! isset( $cache[ $mpid ] ) || ! is_array( $cache[ $mpid ] ) ) {
					$cache[ $mpid ] = array();
				}
				if ( ! isset( $cache[ $mpid ][ $mkey ] ) || ! is_array( $cache[ $mpid ][ $mkey ] ) ) {
					$cache[ $mpid ][ $mkey ] = array();
				}

				// Add a value to the current pid/key.
				$cache[ $mpid ][ $mkey ][] = $mval;
			}
		}

		$data = array();
		foreach ( $non_cached_ids as $id ) {
			if ( ! isset( $cache[ $id ] ) ) {
				$cache[ $id ] = array();
			}
			$data[ $id ] = $cache[ $id ];
		}
		wp_cache_add_multiple( $data, $cache_key );

		return $cache;
	}

	/**
	 * Return Contact IDs based on a specific meta key and optionally a meta value.
	 *
	 * @since 3.3.0
	 *
	 * @param string $meta_key   Meta key to search for.
	 * @param mixed  $meta_value Meta value to search for, optional.
	 *
	 * @return array Array of contact IDs
	 */
	public static function get_contacts_by_meta( $meta_key, $meta_value = null ) {
		global $wpdb;

		if ( empty( $meta_key ) ) {
			return array();
		}

		if ( null === $meta_value ) {
			$contact_ids = $wpdb->get_col(
				$wpdb->prepare(
					'SELECT contact_id FROM %i WHERE meta_key = %s',
					$wpdb->prefix . self::$table . '_meta',
					$meta_key
				)
			);
		} else {
			$contact_ids = $wpdb->get_col(
				$wpdb->prepare(
					'SELECT contact_id FROM %i WHERE meta_key = %s AND meta_value = %s',
					$wpdb->prefix . self::$table . '_meta',
					$meta_key,
					$meta_value
				)
			);
		}

		// Sanitise all results.
		return array_filter( array_unique( array_map( 'intval', $contact_ids ) ) );
	}
}
