<?php
/**
 * Activity 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.

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

	/**
	 * Database table name storing Activities
	 *
	 * @var string
	 */
	protected static string $table = 'pwp_activity';

	/**
	 * Matches to database columns, excluding ID
	 *
	 * @var string[]
	 */
	protected static $attributes = [
		'url',
		'activity_type',
		'object_id',
		'object_type',
		'contact_id',
		'created',
		'lead_score',
		'ip',
		'referrer',
	];

	/**
	 * Record an Activity for a Contact. Creates the Contact if required.
	 *
	 * @since 3.3.0 Added 'data' to $activity_args
	 *
	 * @param string $unique_id      Browser unique ID
	 * @param array  $activity_args {
	 *     Arguments to record.
	 *
	 *     @type string $url           URL of the page the activity took place on
	 *     @type string $activity_type Type of activity to create.
	 *     @type int    $object_id     Object ID for the activity, e.g. Post ID, Term ID. Optional.
	 *     @type array  $data          Array of meta data to record for the activity. Optional.
	 * }
	 *
	 * @return bool True on success, false on failure
	 */
	public static function record( string $unique_id, array $activity_args ) {

		/**
		 * Allow short-circuiting the recording of an Activity.
		 *
		 * Returning a non-null value from the filter will effectively short-circuit the recording
		 * and return the passed value instead.
		 *
		 * @since 3.3.0 Added 'data' to $activity_args
		 * @since 2.1.0
		 *
		 * @param null|bool $check         Whether to allow recording the Activity entry.
		 * @param string    $unique_id     Browser unique ID
		 * @param array     $activity_args {
		 *     @type string $url           URL of the page the activity took place on
		 *     @type string $activity_type Type of activity to create.
		 *     @type int    $object_id     Object ID for the activity, e.g. Post ID, Term ID. Optional.
		 *     @type array  $data          Array of meta data to record for the activity. Optional.
		 * }
		 */
		$check = apply_filters( 'personalizewp_pre_record_activity', null, $unique_id, $activity_args );
		if ( null !== $check ) {
			return (bool) $check;
		}

		// Merge with defaults, skipping Contact ID for now.
		$activity_args = wp_parse_args(
			$activity_args,
			[
				'activity_type' => 'misc',
				'created'       => current_time( 'mysql' ),
				'lead_score'    => 0,
				'url'           => '',
			]
		);

		// IP addresses are PII personal information, so will also be skipped when in full anonymise profiles mode.
		if ( ! personalizewp()->should_anonymise_profiles() ) {
			// This should always be set, still needs sanitizing though.
			$remote_addr_header_ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : false;
			if ( ! empty( $remote_addr_header_ip ) ) {
				$activity_args['ip'] = $remote_addr_header_ip;
			}
		}

		/**
		 * Fires after the Activity arguments have been created, but before it has been saved.
		 *
		 * @since 1.1.0
		 *
		 * @param array  $activity_args Activity arguments.
		 * @param string $type          Activity Type.
		 */
		$activity_args = apply_filters( 'personalizewp_activity_record_data', $activity_args, $activity_args['activity_type'] );

		// At this point Lead Score known, all data for Contact known, only Contact ID needed for Activity creation.
		$user_args = array(
			'lead_score' => $activity_args['lead_score'], // Even if zero
		);
		$contact   = Contact::find_by_uid( $unique_id, $user_args );

		// Safety check to ensure existence.
		if ( ! $contact ) {
			return;
		}

		// Finally add required contact ID.
		$activity_args['contact_id'] = $contact->ID;

		$activity = new Activity( $activity_args );

		$activity_id = $activity->save();

		if ( $activity_id ) {
			$activity->ID = $activity_id;

			// Store optional meta data for activity.
			if ( ! empty( $activity_args['data'] ) ) {
				foreach ( $activity_args['data'] as $field => $value ) {
					self::add_metadata( $activity->ID, $field, $value );
				}
			}

			$type = $activity->activity_type;

			/**
			 * Fires as a specific Activity has been saved.
			 *
			 * The dynamic portion of the hook name, `$type`,
			 * refers to the type of Activity being recorded.
			 *
			 * @since 1.1.0 Added the `$contact` parameter.
			 *
			 * @param Activity $activity  Activity object.
			 * @param Contact  $contact   Contact object.
			 */
			do_action( "personalizewp_activity_record_{$type}", $activity, $contact );

			/**
			 * Fires once an Activity has been saved.
			 *
			 * @since 1.1.0 Added the `$contact` parameter.
			 *
			 * @param string   $type      Activity Type.
			 * @param Activity $activity  Activity object.
			 * @param Contact  $contact   Contact object.
			 */
			do_action( 'personalizewp_activity_record', $type, $activity, $contact );

			wp_cache_delete( 'pwp_pro_activities_by_contact_id_' . $activity->contact_id );

			return true;
		}

		return false;
	}

	/**
	 * Gets the date of most recent Activity for a Contact
	 *
	 * @param int $contact_id Contact ID
	 *
	 * @return string
	 */
	public static function get_contact_last_seen( $contact_id ) {

		_deprecated_function( __FUNCTION__, '2.8.0' );
		$contact = Contact::find( $contact_id );
		return $contact->last_seen;
	}

	/**
	 * Deletes an Activity
	 *
	 * @since 1.1.0
	 *
	 * @param integer $id ID of database row
	 *
	 * @return bool True on success, false on failure
	 */
	public static function delete( int $id ) {
		global $wpdb;

		$activity = self::find( $id );
		if ( false === $activity ) {
			// Nothing to do.
			return false;
		}

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

		/**
		 * Fires before an activity is deleted, at the start of Activity::delete().
		 *
		 * @since 1.1.0
		 *
		 * @param Activity $activity Activity object.
		 */
		do_action( 'personalizewp_before_delete_activity', $activity );

		wp_cache_delete( 'pwp_pro_activities_by_contact_id_' . $activity->contact_id );

		// Couple Contact to Activity as this must occur.
		// Was there a non-zero score attached?
		if ( ! empty( $activity->lead_score ) ) {
			$contact = Contact::get_instance( $activity->contact_id );
			if ( $contact ) {
				// Invert the score and apply.
				$contact->update_lead_score( -1 * (int) $activity->lead_score );
			}
		}

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

		// Clear the meta activity cache.
		wp_cache_delete_multiple( array( $id ), 'pwp_activities_meta' );

		// Use parent delete to remove the Activity itself.
		return parent::delete( $id );
	}

	/**
	 * Get activities of the user by user uid.
	 *
	 * @since 2.9.0
	 *
	 * @param integer $contact_id User id
	 *
	 * @return Array Activities array
	 */
	public static function find_by_contact_id( int $contact_id ) {
		$activities = wp_cache_get( 'pwp_pro_activities_by_contact_id_' . $contact_id );
		if ( false === $activities ) {
			global $wpdb;
			$activities = $wpdb->get_results(
				$wpdb->prepare(
					"SELECT * FROM {$wpdb->prefix}pwp_activity WHERE contact_id = %s ORDER BY ID DESC",
					$contact_id
				),
				ARRAY_A
			);
			wp_cache_set( 'pwp_pro_activities_by_contact_id_' . $contact_id, $activities );
		}
		return $activities;
	}

	/**
	 * Bulk delete activities by user ID. Bypasses actions when deleting a singular activity.
	 *
	 * @since 3.3.0
	 *
	 * @param integer $contact_id User id
	 *
	 * @return bool True on success, false on failure
	 */
	public static function delete_by_contact_id( int $contact_id ) {
		global $wpdb;

		// First delete all the meta data of activites as only later references contact ID. Use table join for speed.
		$result = $wpdb->query(
			$wpdb->prepare(
				"DELETE {$wpdb->prefix}pwp_activity_meta FROM {$wpdb->prefix}pwp_activity_meta
				INNER JOIN {$wpdb->prefix}pwp_activity ON {$wpdb->prefix}pwp_activity.ID = {$wpdb->prefix}pwp_activity_meta.activity_id
				WHERE {$wpdb->prefix}pwp_activity.contact_id = %d",
				$contact_id
			)
		);

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

		// TBA: Clear cache. Note, don't know activity IDs.

		return $result;
	}

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

	/**
	 * Retrieves activity meta field for the given activity ID.
	 *
	 * @since 3.3.0
	 *
	 * @param integer $activity_id Activity ID
	 * @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 static function get_metadata( $activity_id, $meta_key = '', $single = false ) {
		$meta_cache = wp_cache_get( $activity_id, 'pwp_activities_meta' );

		if ( ! $meta_cache ) {
			$meta_cache = self::update_meta_cache( array( $activity_id ) );
			if ( isset( $meta_cache[ $activity_id ] ) ) {
				$meta_cache = $meta_cache[ $activity_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 the given activity ID.
	 *
	 * @since 3.3.0
	 *
	 * @global wpdb $wpdb WordPress database abstraction object.
	 *
	 * @param integer $activity_id Activity ID
	 * @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 static function add_metadata( $activity_id, $meta_key, $meta_value, $unique = false ) {
		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 = sanitize_meta( $meta_key, $meta_value, 'user' );

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

		$_meta_value = $meta_value;
		$meta_value  = maybe_serialize( $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(
				'activity_id' => $activity_id,
				'meta_key'    => $meta_key,
				'meta_value'  => $meta_value,
			)
			// phpcs:enable
		);

		if ( ! $result ) {
			return false;
		}

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

		wp_cache_delete( $activity_id, 'pwp_activities_meta' );

		return $mid;
	}

	/**
	 * Updates metadata for the given activity ID. If no value already exists for the specified
	 * metadata key, the metadata will be added.
	 *
	 * @since 3.3.0
	 *
	 * @global wpdb $wpdb WordPress database abstraction object.
	 *
	 * @param integer $activity_id Activity ID
	 * @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 static function update_metadata( $activity_id, $meta_key, $meta_value ) {
		global $wpdb;

		if ( ! $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 activity_id = %d',
				$wpdb->prefix . self::$table . '_meta',
				$meta_key,
				$activity_id
			)
		);
		if ( empty( $meta_ids ) ) {
			return self::add_metadata( $activity_id, $raw_meta_key, $passed_value );
		}

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

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

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

		wp_cache_delete( $activity_id, 'pwp_activities_meta' );

		return true;
	}

	/**
	 * Deletes metadata for the given activity ID.
	 *
	 * @since 3.3.0
	 *
	 * @global wpdb $wpdb WordPress database abstraction object.
	 *
	 * @param integer $activity_id Activity ID
	 * @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 static function delete_metadata( $activity_id, $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 = maybe_serialize( $meta_value );

		$query = $wpdb->prepare(
			'SELECT meta_id FROM %i WHERE meta_key = %s AND activity_id = %d',
			$wpdb->prefix . self::$table . '_meta',
			$meta_key,
			$activity_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;
		}
		$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( $activity_id );
		wp_cache_delete_multiple( $data, 'pwp_activities_meta' );

		return true;
	}

	/**
	 * Determines if a meta field with the given key exists for given activity ID.
	 *
	 * @since 3.3.0
	 *
	 * @param integer $activity_id Activity ID
	 * @param string  $meta_key    Metadata key.
	 * @return bool Whether a meta field with the given key exists.
	 */
	public static function metadata_exists( $activity_id, $meta_key ) {
		$meta_cache = wp_cache_get( $activity_id, 'pwp_activities_meta' );

		if ( ! $meta_cache ) {
			$meta_cache = self::update_meta_cache( array( $activity_id ) );
			$meta_cache = $meta_cache[ $activity_id ];
		}

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

		return false;
	}

	/**
	 * Updates the metadata cache for the specified activities.
	 *
	 * @since 3.3.0
	 *
	 * @param string|int[] $ids Array or comma delimited list of IDs to update cache for.
	 * @return array|false Metadata cache for the specified activities, 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_activities_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 activity_id, meta_key, meta_value FROM %i WHERE activity_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['activity_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;
	}
}
