<?php
/**
 * Student Model
 *
 * @package LifterLMS/Models/Classes
 *
 * @since 2.2.3
 * @version 7.5.0
 */

defined( 'ABSPATH' ) || exit;

/**
 * LLMS_Student model class
 *
 * Manages data and interactions with a LifterLMS Student.
 *
 * @since 2.2.3
 * @since 3.33.0 Added the `delete_student_enrollment` public method that allows student's enrollment unrollment and deletion.
 * @since 3.33.0 Added the `delete_enrollment_postmeta` private method that allows student's enrollment postmeta deletion.
 * @since 3.34.0 Added new filters for differentiating between enrollment update and creation; Added the ability to check enrollment from a section.
 * @since 3.35.0 Prepare all variables when querying for enrollment date.
 * @since 3.36.2 Added logic to physically remove from the membership level and remove enrollments data on related products, when deleting a membership enrollment.
 * @since 3.37.9 Added filters `llms_user_enrollment_allowed_post_types` & `llms_user_enrollment_status_allowed_post_types` which allow 3rd parties to enroll users into additional post types via core enrollment methods.
 * @since 4.0.0 Remove previously deprecated methods.
 * @since 4.2.0 The `$enrollment_trigger` parameter was added to the `'llms_user_enrollment_deleted'` action hook.
 *              Added new filter to allow customization of object completion data.
 * @since 5.2.0 Changed the date to be relative to the local time zone in `get_registration_date`.
 * @since 6.0.0 Removed the deprecated `llms_user_removed_from_membership_level` action hook from the `LLMS_Student::unenroll()` method.
 * @since 7.5.0 Added the logic to add and remove lesson favorite.
 */
class LLMS_Student extends LLMS_Abstract_User_Data {

	use LLMS_Trait_Student_Awards;

	/**
	 * Retrieve an instance of the LLMS_Instructor model for the current user
	 *
	 * @return   LLMS_Instructor|false
	 * @since    3.14.0
	 * @version  3.14.0
	 */
	public function instructor() {
		if ( $this->is_instructor() ) {
			return llms_get_instructor( $this->get_id() );
		}
		return false;
	}

	/**
	 * Retrieve an instance of the student quiz data model
	 *
	 * @return   LLMS_Student_Quizzes
	 * @since    3.9.0
	 * @version  3.9.0
	 */
	public function quizzes() {
		return new LLMS_Student_Quizzes( $this->get_id() );
	}

	/**
	 * Add the student to a LifterLMS Membership
	 *
	 * @param int $membership_id   WP Post ID of the membership
	 * @return  void
	 *
	 * @since  2.2.3
	 */
	private function add_membership_level( $membership_id ) {

		// Add the user to the membership level.
		$membership_levels = $this->get_membership_levels();
		array_push( $membership_levels, $membership_id );
		update_user_meta( $this->get_id(), '_llms_restricted_levels', $membership_levels );

		// If there's auto-enroll courses, enroll the user in those courses.
		$autoenroll_courses = get_post_meta( $membership_id, '_llms_auto_enroll', true );
		if ( $autoenroll_courses ) {

			foreach ( $autoenroll_courses as $course_id ) {

				$this->enroll( $course_id, 'membership_' . $membership_id );

			}
		}
	}

	/**
	 * Enroll the student into a course or membership
	 *
	 * @since 2.2.3
	 * @since 3.17.0 Unknown.
	 * @since 3.34.0 Added new actions to differentiate between first-time enrollment and enrollment status updates.
	 * @since 3.37.9 Added filter `llms_user_enrollment_allowed_post_types` to customize the post types a user can be enrolled into.
	 * @since 4.4.1 Moved filter `llms_user_enrollment_allowed_post_types` to function `llms_get_enrollable_post_types()`.
	 *
	 * @see llms_enroll_student()
	 *
	 * @param  int    $product_id WP Post ID of the course or membership
	 * @param  string $trigger    String describing the reason for enrollment
	 * @return bool
	 */
	public function enroll( $product_id, $trigger = 'unspecified' ) {

		/**
		 * Fires before a user is enrolled into a course or membership.
		 *
		 * @param int $user_id WP User ID.
		 * @param int $product_id WP Post ID of the course or membership.
		 */
		do_action( 'before_llms_user_enrollment', $this->get_id(), $product_id );

		// Users can only be enrolled into the following post types.
		if ( ! in_array( get_post_type( $product_id ), llms_get_enrollable_post_types(), true ) ) {
			return false;
		}

		// Check enrollment before enrolling to prevent duplicates.
		if ( llms_is_user_enrolled( $this->get_id(), $product_id ) ) {
			return false;
		}

		// If the student has been previously enrolled, simply update don't run a full enrollment.
		if ( $this->get_enrollment_status( $product_id, false ) ) {
			$insert      = $this->insert_status_postmeta( $product_id, 'enrolled', $trigger );
			$action_type = 'updated';
		} else {
			$insert      = $this->insert_enrollment_postmeta( $product_id, $trigger );
			$action_type = 'created';
		}

		// Add the user postmeta for the enrollment.
		if ( ! empty( $insert ) ) {

			// Update the cache.
			$this->cache_set( sprintf( 'enrollment_status_%d', $product_id ), 'enrolled' );
			$this->cache_delete( sprintf( 'date_enrolled_%d', $product_id ) );
			$this->cache_delete( sprintf( 'date_updated_%d', $product_id ) );

			$post_type = str_replace( 'llms_', '', get_post_type( $product_id ) );

			if ( 'course' === $post_type ) {

				/**
				 * Fires after a user is enrolled in course
				 *
				 * @param int $user_id    WP User ID.
				 * @param int $product_id WP Post ID of the course or membership.
				 */
				do_action( 'llms_user_enrolled_in_course', $this->get_id(), $product_id );

			} elseif ( 'membership' === $post_type ) {

				$this->add_membership_level( $product_id );

				/**
				 * Fires after a user is enrolled in membership
				 *
				 * @param int $user_id    WP User ID.
				 * @param int $product_id WP Post ID of the course or membership.
				 */
				do_action( 'llms_user_added_to_membership_level', $this->get_id(), $product_id );

			}

			/**
			 * Fires after a user's enrollment is created or updated.
			 *
			 * `$post_type` refers to the type of item the user is enrolled in, either 'course' or 'membership'
			 * `$action_type` refers to the type of action taking place, either "created" or "updated".
			 *
			 * @param int $user_id WP User ID.
			 * @param int $product_id WP Post ID of the course or membership.
			 */
			do_action( "llms_user_{$post_type}_enrollment_{$action_type}", $this->get_id(), $product_id );

			return true;

		}

		return false;
	}

	public function get_avatar( $size = 96 ) {
		return '<span class="llms-student-avatar">' . get_avatar( $this->get_id(), $size, null, $this->get_name() ) . '</span>';
	}

	/**
	 * Retrieve the order which enrolled a student in a given course or membership.
	 *
	 * Retrieves the most recently updated order for the given product.
	 *
	 * @since 3.0.0
	 * @since 5.7.0 Replaced the call to the deprecated `LLMS_Lesson::get_parent_course()` method with `LLMS_Lesson::get( 'parent_course' )`.
	 *
	 * @param int $product_id WP Post ID of the LifterLMS Product (course, lesson, or membership)
	 * @return LLMS_Order|false Instance of the LLMS_Order or false if none found
	 */
	public function get_enrollment_order( $product_id ) {

		// If a lesson id was passed in, cascade up to the course for order retrieval.
		if ( 'lesson' === get_post_type( $product_id ) ) {
			$lesson     = new LLMS_Lesson( $product_id );
			$product_id = $lesson->get( 'parent_course' );
		}

		// Attempt to locate the order via the enrollment trigger.
		$trigger = $this->get_enrollment_trigger( $product_id );
		if ( strpos( $trigger, 'order_' ) !== false ) {

			$id = str_replace( array( 'order_', 'wc_' ), '', $trigger );
			if ( is_numeric( $id ) ) {
				if ( 'llms_order' === get_post_type( $id ) ) {
					return new LLMS_Order( $id );
				} else {

					return get_post( $id );
				}
			}
		}

		// Couldn't find via enrollment trigger, do a WP_Query.
		$q = new WP_Query(
			array(
				'order'          => 'DESC',
				'orderby'        => 'modified',
				'meta_query'     => array(
					'relation' => 'AND',
					array(
						'key'   => '_llms_user_id',
						'value' => $this->get_id(),
					),
					array(
						'key'   => '_llms_product_id',
						'value' => $product_id,
					),
				),
				'posts_per_page' => 1,
				'post_type'      => 'llms_order',
			)
		);

		if ( $q->have_posts() ) {
			return new LLMS_Order( $q->posts[0] );
		}

		// Couldn't find an order, return false.
		return false;
	}

	/**
	 * Retrieve IDs of user's courses based on supplied criteria
	 *
	 * @param    array $args   see `get_enrollments`
	 * @return   array
	 * @since    3.0.0
	 * @version  3.15.0
	 */
	public function get_courses( $args = array() ) {

		return $this->get_enrollments( 'course', $args );
	}

	/**
	 * Retrieve user's favorites based on supplied criteria.
	 *
	 * @since 7.5.0
	 *
	 * @param string $order_by Result set ordering field. Default "updated_date".
	 * @param string $order    Result set order. Default "DESC". Accepts "DESC" or "ASC".
	 * @param int    $limit    Number of favorites to return. Default is infinite.
	 * @return bool|array
	 */
	public function get_favorites( $order_by = 'updated_date', $order = 'DESC', $limit = -1 ) {

		global $wpdb;

		$limit_clause = $limit < 1 ? '' : 'LIMIT 0, ' . intval( $limit );

		$res = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT * FROM {$wpdb->prefix}lifterlms_user_postmeta
					WHERE meta_key = %s AND user_id = %d ORDER BY %s %s %s;",
				'_favorite',
				get_current_user_id(),
				$order_by,
				'DESC' === $order ? 'DESC' : 'ASC',
				$limit_clause
			)
		);

		return empty( $res ) ? false : $res;
	}

	/**
	 * Retrieve IDs of courses a user has completed
	 *
	 * @param  array $args query arguments
	 *                      @arg int    $limit    number of courses to return
	 *                      @arg string $orderby  table reference and field to order results by
	 *                      @arg string $order    result order (DESC, ASC)
	 *                      @arg int    $skip     number of results to skip for pagination purposes
	 * @return array        "courses" will contain an array of course ids
	 *                      "more" will contain a boolean determining whether or not more courses are available beyond supplied limit/skip criteria
	 * @since   ??
	 * @version 3.24.0
	 */
	public function get_completed_courses( $args = array() ) {

		global $wpdb;

		$args = array_merge(
			array(
				'limit'   => 20,
				'orderby' => 'upm.updated_date',
				'order'   => 'DESC',
				'skip'    => 0,
			),
			$args
		);

		// Add one to the limit to see if there's pagination.
		++$args['limit'];

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$q = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT upm.post_id AS id
			 FROM {$wpdb->prefix}lifterlms_user_postmeta AS upm
			 JOIN {$wpdb->posts} AS p ON p.ID = upm.post_id
			 WHERE p.post_type = 'course'
			   AND upm.meta_key = '_is_complete'
			   AND upm.meta_value = 'yes'
			   AND upm.user_id = %d
			 ORDER BY {$args['orderby']} {$args['order']}
			 LIMIT %d, %d;
			",
				array(
					$this->get_id(),
					$args['skip'],
					$args['limit'],
				)
			),
			'OBJECT_K'
		); // db call ok; no-cache ok.
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		$ids  = array_keys( $q );
		$more = false;

		// If we hit our limit we have too many results, pop the last one.
		if ( count( $ids ) === $args['limit'] ) {
			array_pop( $ids );
			$more = true;
		}

		// Reset args to pass back for pagination.
		--$args['limit'];

		$r = array(
			'limit'   => $args['limit'],
			'more'    => $more,
			'results' => $ids,
			'skip'    => $args['skip'],
		);

		return $r;
	}

	/**
	 * Get the formatted date when a course or lesson was completed by the student
	 *
	 * @param    int    $object_id  WP Post ID of a course or lesson
	 * @param    string $format     date format as accepted by php date()
	 * @return   false|string            will return false if the user is not enrolled
	 * @since    ??
	 * @version  ??
	 */
	public function get_completion_date( $object_id, $format = 'F d, Y' ) {

		global $wpdb;

		$q = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT updated_date FROM {$wpdb->prefix}lifterlms_user_postmeta WHERE meta_key = '_is_complete' AND meta_value = 'yes' AND user_id = %d AND post_id = %d ORDER BY updated_date DESC LIMIT 1",
				array( $this->get_id(), $object_id )
			)
		); // db call ok; no-cache ok.

		return ( $q ) ? date_i18n( $format, strtotime( $q ) ) : false;
	}

	/**
	 * Retrieve IDs of user's enrollments by post type (and additional criteria)
	 *
	 * @param  string $post_type  name of the post type (course|membership)
	 * @param  array  $args query arguments
	 *                      @arg int    $limit    number of courses to return
	 *                      @arg string $orderby  table reference and field to order results by
	 *                      @arg string $order    result order (DESC, ASC)
	 *                      @arg int    $skip     number of results to skip for pagination purposes
	 *                      @arg string $status   filter results by enrollment status, "any", "enrolled", "cancelled", or "expired"
	 * @return array        "results" will contain an array of course ids
	 *                      "more" will contain a boolean determining whether or not more courses are available beyond supplied limit/skip criteria
	 *                      "found" will contain the total possible FOUND_ROWS() for the query
	 * @since    3.0.0
	 * @version  3.15.1
	 */
	public function get_enrollments( $post_type = 'course', $args = array() ) {

		global $wpdb;

		$args = wp_parse_args(
			$args,
			array(
				'limit'   => 20,
				'orderby' => 'upm.updated_date',
				'order'   => 'DESC',
				'skip'    => 0,
				'status'  => 'any', // Any, enrolled, cancelled, expired.
			)
		);

		// Prefix membership.
		if ( 'membership' === $post_type ) {
			$post_type = 'llms_membership';
		}

		// Sanitize order & orderby.
		$args['orderby'] = preg_replace( '/[^a-zA-Z_.]/', '', $args['orderby'] );
		$args['order']   = preg_replace( '/[^a-zA-Z_.]/', '', $args['order'] );

		// Allow "short" orderby's to be passed in without a table reference.
		switch ( $args['orderby'] ) {
			case 'date':
				$args['orderby'] = 'upm.updated_date';
				break;
			case 'order':
				$args['orderby'] = 'p.menu_order';
				break;
			case 'title':
				$args['orderby'] = 'p.post_title';
				break;
		}

		// Prepare additional status AND clauses.
		if ( 'any' !== $args['status'] ) {
			$status = $wpdb->prepare(
				"
				AND upm.meta_value = %s
				AND upm.updated_date = (
					SELECT MAX( upm2.updated_date )
					  FROM {$wpdb->prefix}lifterlms_user_postmeta AS upm2
					 WHERE upm2.meta_key = '_status'
					   AND upm2.user_id = %d
					   AND upm2.post_id = upm.post_id
					)",
				$args['status'],
				$this->get_id()
			);
		} else {
			$status = '';
		}

		$from_where = "FROM {$wpdb->prefix}lifterlms_user_postmeta AS upm
			 JOIN {$wpdb->posts} AS p ON p.ID = upm.post_id
			 WHERE p.post_type = %s
			   AND p.post_status = 'publish'
			   AND upm.meta_key = '_status'
			   AND upm.user_id = %d
			   {$status}";

		$prepare_args = array( $post_type, $this->get_id() );

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$found = absint(
			$wpdb->get_var(
				$wpdb->prepare(
					"SELECT COUNT(DISTINCT upm.post_id) {$from_where}",
					$prepare_args
				)
			)
		); // db call ok; no-cache ok.

		$query = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT DISTINCT upm.post_id AS id
			 {$from_where}
			 ORDER BY {$args['orderby']} {$args['order']}
			 LIMIT %d, %d;
			",
				array_merge(
					$prepare_args,
					array( $args['skip'], $args['limit'] )
				)
			),
			'OBJECT_K'
		); // db call ok; no-cache ok.
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		return array(
			'found'   => $found,
			'limit'   => $args['limit'],
			'more'    => ( $found > ( ( $args['skip'] / $args['limit'] + 1 ) * $args['limit'] ) ),
			'skip'    => $args['skip'],
			'results' => array_keys( $query ),
		);
	}

	/**
	 * Get the formatted date when a user initially enrolled in a product or when they were last updated
	 *
	 * @since 3.0.0
	 * @since 3.35.0 Prepare SQL properly.
	 *
	 * @param   int    $product_id  WP Post ID of a course or membership
	 * @param   string $date        "enrolled" will get the most recent start date, "updated" will get the most recent status change date
	 * @param   string $format      date format as accepted by php date(), if none supplied uses the WP core "date_format" option
	 * @return  false|string        will return false if the user is not enrolled
	 */
	public function get_enrollment_date( $product_id, $date = 'enrolled', $format = null ) {

		if ( ! $format ) {
			$format = get_option( 'date_format', 'M d, Y' );
		}

		$cache_key = sprintf( 'date_%1$s_%2$s', $date, $product_id );
		$res       = $this->cache_get( $cache_key );

		if ( false === $res ) {

			$key = ( 'enrolled' === $date ) ? '_start_date' : '_status';

			global $wpdb;

			// Get the oldest recorded Enrollment date.
			$res = $wpdb->get_var(
				$wpdb->prepare(
					"SELECT updated_date FROM {$wpdb->prefix}lifterlms_user_postmeta WHERE meta_key = %s AND user_id = %d AND post_id = %d ORDER BY updated_date DESC LIMIT 1",
					array( $key, $this->get_id(), $product_id )
				)
			);

			$this->cache_set( $cache_key, $res );

		}

		return ( $res ) ? date_i18n( $format, strtotime( $res ) ) : false;
	}

	/**
	 * Get the current enrollment status of a student for a particular product
	 *
	 * @since 3.0.0
	 * @since 3.17.0 Unknown.
	 * @since 3.37.9 Added filter `llms_user_enrollment_status_allowed_post_types`.
	 * @since 4.4.1 Moved filter `llms_user_enrollment_status_allowed_post_types` to function `llms_get_enrollable_status_check_post_types()`.
	 * @since 4.18.0 Added a tie-breaker when there are multiple enrollment statuses with the same date & time.
	 * @since 5.7.0 Replaced the call to the deprecated `LLMS_Lesson::get_parent_course()` method with `LLMS_Lesson::get( 'parent_course' )`.
	 *
	 * @param  int  $product_id  WP Post ID of a Course, Section, Lesson, or Membership
	 * @param  bool $use_cache   If true, returns cached data if available, if false will run a db query
	 * @return false|string      When no enrollment status exists, returns `false`. Otherwise returns the
	 *                           enrollment status as a string.
	 */
	public function get_enrollment_status( $product_id, $use_cache = true ) {

		$status       = false;
		$product_type = get_post_type( $product_id );

		if ( ! in_array( $product_type, llms_get_enrollable_status_check_post_types(), true ) ) {
			/* This filter is documented at the end of this method. */
			return apply_filters( 'llms_get_enrollment_status', $status, $this->get_id(), $product_id, $use_cache );
		}

		// Get course ID if we're looking at a lesson or section.
		if ( in_array( $product_type, array( 'section', 'lesson' ), true ) ) {

			$llms_post = llms_get_post( $product_id );
			if ( $llms_post ) {
				$product_id = $llms_post->get( 'parent_course' );
			}
		}

		if ( $use_cache ) {
			$status = $this->cache_get( sprintf( 'enrollment_status_%d', $product_id ) );
		}

		/**
		 * After checking the cache, $status will be:
		 *     + `false` if there was nothing in the cache or the function was instructed to not use the cache: Query the database to get the status.
		 *     + a string if there was a status: No need to query the database.
		 *     + `null` if there's no status: No need to query the database.
		 */
		if ( false === $status ) {

			global $wpdb;

			// Get the most recent recorded status.
			$status = $wpdb->get_var(
				$wpdb->prepare(
					"SELECT meta_value FROM {$wpdb->prefix}lifterlms_user_postmeta
					 WHERE meta_key = '_status' AND user_id = %d AND post_id = %d
					 ORDER BY updated_date DESC, meta_id DESC LIMIT 1;",
					array( $this->get_id(), $product_id )
				)
			);

			// Cache the data: `null` will be stored if the student has no status.
			$this->cache_set( sprintf( 'enrollment_status_%d', $product_id ), $status );

		}

		// Don't return `null` values from the database.
		$status = $status ? $status : false;

		/**
		 * Filter a user's enrollment status for a specific post.
		 *
		 * Note that if a value is modified by this filter the modified value is *not* cached. Therefore you should
		 * consider implementing caching of your modified value which matches the caching implemented by this method
		 * so that the modified value obeys the default caching behavior.
		 *
		 * @since Unknown
		 *
		 * @param false|string $status     When no enrollment status exists, returns `false`. Otherwise returns the
		 *                                     enrollment status as a string.
		 * @param int          $user_id    WP_User ID of the student
		 * @param int          $product_id WP_Post ID of the post used to check the enrollment status.
		 * @param boolean      $use_cache  Whether or not to use the local cache.
		 */
		return apply_filters( 'llms_get_enrollment_status', $status, $this->get_id(), $product_id, $use_cache );
	}

	/**
	 * Get the enrollment trigger for a the student's enrollment in a course
	 *
	 * @param    int $product_id   WP Post ID of the course or membership
	 * @return   string|false
	 * @since    ??
	 * @version  3.21.0
	 */
	public function get_enrollment_trigger( $product_id ) {

		$trigger = llms_get_user_postmeta( $this->get_id(), $product_id, '_enrollment_trigger', true );
		return $trigger ? $trigger : false;
	}

	/**
	 * Get the enrollment trigger id for a the student's enrollment in a course
	 *
	 * @param    int $product_id  WP Post ID of the course or membership
	 * @return   int|false
	 * @since    3.0.0
	 * @version  3.17.2
	 */
	public function get_enrollment_trigger_id( $product_id ) {

		$trigger = $this->get_enrollment_trigger( $product_id );
		$id      = false;
		if ( $trigger && false !== strpos( $trigger, 'order_' ) ) {
			$trigger_obj = $this->get_enrollment_order( $product_id );
			if ( $trigger_obj instanceof LLMS_Order ) {
				$id = $trigger_obj->get( 'id' );
			} elseif ( $trigger_obj instanceof WP_Post ) {
				$id = $trigger_obj->ID;
			}
		} elseif ( $trigger && false !== strpos( $trigger, 'admin_' ) ) {
			$id = absint( str_replace( 'admin_', '', $trigger ) );
		}
		return $id;
	}

	/**
	 * Retrieve postmeta events related to the student
	 *
	 * @param    array $args  default args, see LLMS_Query_User_Postmeta
	 * @return   array
	 * @since    3.15.0
	 * @version  3.15.0
	 */
	public function get_events( $args = array() ) {

		$query = new LLMS_Query_User_Postmeta(
			wp_parse_args(
				$args,
				array(
					'types'         => 'all',
					'per_page'      => 10,
					'user_id'       => $this->get_id(),
					'no_found_rows' => true,
				)
			)
		);

		return $query->get_metas();
	}

	/**
	 * Get the students grade for a lesson / course
	 * All grades are based on quizzes assigned to lessons
	 *
	 * @param    int  $object_id  WP Post ID of a course or lesson
	 * @param    bool $use_cache  If true, uses cached results
	 * @return   mixed
	 * @since    ??
	 * @version  3.24.0
	 */
	public function get_grade( $object_id, $use_cache = true ) {
		$grade = llms()->grades()->get_grade( $object_id, $this, $use_cache );
		if ( is_null( $grade ) ) {
			$grade = _x( 'N/A', 'Grade to display when no quizzes taken or available', 'lifterlms' );
		}
		return apply_filters( 'llms_student_get_grade', $grade, $this, $object_id, get_post_type( $object_id ) );
	}

	/**
	 * Retrieve IDs of user's memberships based on supplied criteria
	 *
	 * @param    array $args   see `get_enrollments`
	 * @return   array
	 * @since    3.15.0
	 * @version  3.15.0
	 */
	public function get_memberships( $args = array() ) {

		return $this->get_enrollments( 'membership', $args );
	}

	/**
	 * Retrieve a user's notification subscription preferences for a given type & trigger
	 *
	 * @param    string $type     notification type: email, basic, etc...
	 * @param    string $trigger  notification trigger: eg purchase_receipt, lesson_complete, etc...
	 * @param    string $default  value to return if no setting is saved in the db
	 * @return   string             yes or no
	 * @since    3.10.0
	 * @version  3.10.0
	 */
	public function get_notification_subscription( $type, $trigger, $default = 'no' ) {

		$prefs = $this->get( 'notification_subscriptions' );
		if ( ! $prefs ) {
			$prefs = array();
		}

		if ( isset( $prefs[ $type ] ) && isset( $prefs[ $type ][ $trigger ] ) ) {
			return $prefs[ $type ][ $trigger ];
		}

		return $default;
	}

	/**
	 * Retrieve the student's overall grade
	 *
	 * Grade = sum of grades for all courses divided by number of enrolled courses
	 * if a course has no quizzes in it, it cannot be graded and is therefore excluded from the calculation.
	 *
	 * Cached data is automatically cleared when a student completes a quiz.
	 *
	 * @since 3.2.0
	 *
	 * @param boolean $use_cache If `false`, calculates the grade, otherwise utilizes cached data (if available)
	 * @return float|string Grade as float or "N/A"
	 */
	public function get_overall_grade( $use_cache = true ) {

		$grade = null;

		// Attempt to pull from the cache first.
		if ( $use_cache ) {

			$grade = $this->get( $this->meta_prefix . 'overall_grade' );

			if ( is_numeric( $grade ) ) {
				$grade = floatval( $grade );
			}
		}

		// Cache disabled or no cached data available.
		if ( ! $use_cache || null === $grade || '' === $grade ) {

			$grades = array();

			// Get courses.
			$courses = $this->get_courses(
				array(
					'limit' => 9999,
				)
			);

			// Loop through courses.
			foreach ( $courses['results'] as $course_id ) {

				// Get course grade.
				$g = $this->get_grade( $course_id );

				// If an actual grade (not N/A) is returned.
				if ( is_numeric( $g ) ) {
					array_push( $grades, $g );
				}
			}

			// If we have at least one grade.
			$count = count( $grades );
			if ( $count ) {

				$grade = round( array_sum( $grades ) / $count, 2 );

			} else {

				$grade = _x( 'N/A', 'overall grade when no quizzes', 'lifterlms' );

			}

			// Cache the grade.
			$this->set( 'overall_grade', $grade );

		}

		return apply_filters( 'llms_student_get_overall_grade', $grade, $this );
	}

	/**
	 * Retrieve a student's overall progress
	 * Overall progress is the total percentage completed based on all courses the student is enrolled in
	 * Cached data is cleared every time the student completes a lesson
	 *
	 * @param    boolean $use_cache  if false, calculates the progress, otherwise utilizes cached data (if available)
	 * @return   float
	 * @since    3.2.0
	 * @version  3.2.0
	 */
	public function get_overall_progress( $use_cache = true ) {

		$progress = null;

		// Attempt to pull from the cache first.
		if ( $use_cache ) {

			$progress = $this->get( $this->meta_prefix . 'overall_progress' );

			if ( is_numeric( $progress ) ) {
				$progress = floatval( $progress );
			}
		}

		// Cache disabled or no cached data available.
		if ( ! $use_cache || null === $progress || '' === $progress ) {

			$progresses = array();

			// Get courses.
			$courses = $this->get_courses(
				array(
					'limit' => 9999,
				)
			);

			// Loop through courses.
			foreach ( $courses['results'] as $course_id ) {
				array_push( $progresses, $this->get_progress( $course_id, 'course' ) );
			}

			$count = count( $progresses );
			if ( $count ) {

				$progress = round( array_sum( $progresses ) / $count, 2 );

			} else {

				$progress = 0;

			}

			// Cache the grade.
			$this->set( 'overall_progress', $progress );

		}

		return apply_filters( 'llms_student_get_overall_progress', $progress, $this );
	}

	/**
	 * Get the students last completed lesson in a course
	 *
	 * @param    int $course_id    WP_Post ID of the course
	 * @return   int                   WP_Post ID of the lesson or false if no progress has been made
	 * @since    3.0.0
	 * @version  3.0.0
	 */
	public function get_last_completed_lesson( $course_id ) {

		$course  = new LLMS_Course( $course_id );
		$lessons = array_reverse( $course->get_lessons( 'ids' ) );

		foreach ( $lessons as $lesson ) {
			if ( $this->is_complete( $lesson, 'lesson' ) ) {
				return $lesson;
			}
		}

		return false;
	}

	public function has_unlimited_quiz_time() {
		$unlimited = get_user_option( 'llms_allow_unlimited_quiz_time', $this->get_id() );
		return ( 'yes' === $unlimited );
	}

	/**
	 * Retrieve an array of Membership Levels for a user
	 *
	 * @return array
	 * @since   2.2.3
	 * @version 2.2.3
	 */
	public function get_membership_levels() {

		$levels = get_user_meta( $this->get_id(), '_llms_restricted_levels', true );

		if ( empty( $levels ) ) {

			$levels = array();

		}

		return $levels;
	}

	/**
	 * Get the full name of a student
	 *
	 * @return   string
	 * @since    3.0.4
	 * @version  3.5.1
	 */
	public function get_name() {

		$name = trim( $this->get( 'first_name' ) . ' ' . $this->get( 'last_name' ) );

		if ( ! $name ) {
			$name = $this->display_name;
		}

		return apply_filters( 'llms_student_get_name', $name, $this->get_id(), $this );
	}

	/**
	 * Get the next lesson a student needs to complete in a course
	 *
	 * @param    int $course_id    WP_Post ID of the course
	 * @return   int                   WP_Post ID of the lesson or false if all courses are complete
	 * @since    3.0.1
	 * @version  3.0.1
	 */
	public function get_next_lesson( $course_id ) {

		$course  = new LLMS_Course( $course_id );
		$lessons = $course->get_lessons( 'ids' );

		foreach ( $lessons as $lesson ) {
			if ( ! $this->is_complete( $lesson, 'lesson' ) ) {
				return $lesson;
			}
		}

		return false;
	}

	public function get_orders( $params = array() ) {

		$params = wp_parse_args(
			$params,
			array(

				'count'    => 25,
				'page'     => 1,
				'statuses' => array_keys( llms_get_order_statuses() ),

			)
		);

		extract( $params );

		$q = new WP_Query(
			array(
				'order'          => 'DESC',
				'orderby'        => 'date',
				'meta_query'     => array(
					array(
						'key'   => '_llms_user_id',
						'value' => $this->get_id(),
					),
				),
				'paged'          => $page,
				'posts_per_page' => $count,
				'post_status'    => $statuses,
				'post_type'      => 'llms_order',
			)
		);

		$orders = array();

		if ( $q->have_posts() ) {

			foreach ( $q->posts as $post ) {

				$orders[ $post->ID ] = new LLMS_Order( $post );

			}
		}

		return array(
			'count'  => count( $q->posts ),
			'page'   => $page,
			'pages'  => $q->max_num_pages,
			'orders' => $orders,
		);
	}

	/**
	 * Get students progress through a course or track
	 *
	 * @param    int     $object_id  course or track id
	 * @param    string  $type       object type [course|course_track|section]
	 * @param    boolean $use_cache  if true, will use cached data from the usermeta table (if available)
	 *                               if false, will bypass cached data and recalculate the progress from scratch
	 * @return   float
	 * @since    3.0.0
	 * @version  3.24.0
	 */
	public function get_progress( $object_id, $type = 'course', $use_cache = true ) {

		$ret       = 0;
		$cache_key = sprintf( '%1$s_%2$d_progress', $type, $object_id );
		$cached    = $use_cache ? $this->get( $cache_key ) : '';

		if ( '' === $cached ) {

			$total     = 0;
			$completed = 0;

			if ( 'course' === $type ) {

				$course  = new LLMS_Course( $object_id );
				$lessons = $course->get_lessons( 'ids' );
				$total   = count( $lessons );
				foreach ( $lessons as $lesson ) {
					if ( $this->is_complete( $lesson, 'lesson' ) ) {
						++$completed;
					}
				}
			} elseif ( 'course_track' === $type ) {

				$track   = new LLMS_Track( $object_id );
				$courses = $track->get_courses();
				$total   = count( $courses );
				foreach ( $courses as $course ) {
					if ( $this->is_complete( $course->ID, 'course' ) ) {
						++$completed;
					}
				}
			} elseif ( 'section' === $type ) {

				$section = new LLMS_Section( $object_id );
				$lessons = $section->get_lessons( 'ids' );
				$total   = count( $lessons );
				foreach ( $lessons as $lesson ) {
					if ( $this->is_complete( $lesson, 'lesson' ) ) {
						++$completed;
					}
				}
			}

			$ret = ( ! $completed || ! $total ) ? 0 : round( 100 / ( $total / $completed ), 2 );
			$this->set( $cache_key, $ret );

		} else {
			$ret = $cached;
		}// End if().

		/**
		 * @filter llms_student_get_progress
		 * Filters the return of get_progress method
		 * @param    float   $ret        student's progress
		 * @param    int     $object_id  WP_Post ID of the object
		 * @param    string  $type       object post type [course|course_track|section]
		 * @param    int     $user_id    WP_User ID of the student
		 * @since    unknown
		 * @version  3.24.0
		 */
		return apply_filters( 'llms_student_get_progress', $ret, $object_id, $type, $this->get_id() );
	}

	/**
	 * Retrieve the student's original registration date in the chosen format.
	 *
	 * @since Unknown
	 * @since 5.2.0 Changed the date to be relative to the local time zone.
	 *
	 * @param string $format Any date format that can be passed to date().
	 * @return string
	 */
	public function get_registration_date( $format = '' ) {

		if ( ! $format ) {
			$format = get_option( 'date_format' );
		}

		return wp_date( $format, strtotime( $this->get( 'user_registered' ) ) );
	}

	/**
	 * Determine if the student is active in at least one course or membership
	 *
	 * @since 3.14.0
	 *
	 * @return bool
	 */
	public function is_active() {

		// Check memberships first, it's a faster query.
		if ( $this->get_membership_levels() ) {
			return true;
		}

		// Check for at least one enrolled course.
		$courses = $this->get_courses(
			array(
				'limit'  => 1,
				'status' => 'enrolled',
			)
		);

		if ( $courses['results'] ) {
			return true;
		}

		// Not active.
		return false;
	}

	/**
	 * Determine if the student has completed a course, track, or lesson
	 *
	 * @param    int    $object_id  WP Post ID of a course or lesson or section or the term id of the track
	 * @param    string $type    Object type (course, lesson, section, or track)
	 * @return   boolean
	 * @since    3.0.0
	 * @version  3.24.0
	 */
	public function is_complete( $object_id, $type = 'course' ) {

		// check tracks by progress
		// this is done because tracks can have the same id as another object...
		// @todo tracks should have a different table or format since the post_id col won't guarantee uniqueness...
		if ( 'course_track' === $type ) {

			$ret = ( 100 == $this->get_progress( $object_id, $type ) );

			// Everything else can be checked on the postmeta table.
		} else {

			$query = new LLMS_Query_User_Postmeta(
				array(
					'types'                 => 'completion',
					'include_post_children' => false,
					'user_id'               => $this->get_id(),
					'post_id'               => $object_id,
					'per_page'              => 1,
					'no_found_rows'         => true,
				)
			);

			$ret = $query->has_results();

		}

		return apply_filters( 'llms_is_' . $type . '_complete', $ret, $object_id, $type, $this );
	}

	/**
	 * Determine if the student is a LifterLMS Instructor (of any kind)
	 *
	 * Can be admin, manager, instructor, assistant.
	 *
	 * @return   boolean
	 * @since    3.14.0
	 * @version  3.14.0
	 */
	public function is_instructor() {
		return $this->user->has_cap( 'lifterlms_instructor' );
	}

	/**
	 * Add student postmeta data for completion of a lesson, section, course or track
	 *
	 * @param  int    $object_id    WP Post ID of the lesson, section, course or track
	 * @param  string $trigger      String describing the reason for mark completion
	 * @return bool
	 * @since    3.3.1
	 * @version  3.21.0
	 */
	private function insert_completion_postmeta( $object_id, $trigger = 'unspecified' ) {

		// Add info to the user postmeta table.
		$user_metadatas = array(
			'_is_complete'        => 'yes',
			'_completion_trigger' => $trigger,
		);

		$update = llms_bulk_update_user_postmeta( $this->get_id(), $object_id, $user_metadatas, false );

		// Returns an array with errored keys or true on success.
		return is_array( $update ) ? false : true;
	}

	/**
	 * Add student postmeta data for incompletion of a lesson, section, course or track
	 * An "_is_complete" value of "no" is inserted into postmeta
	 *
	 * @param    int    $object_id    WP Post ID of the lesson, section, course or track
	 * @param    string $trigger      String describing the reason for mark incompletion
	 * @return   boolean
	 * @since    3.5.0
	 * @version  3.24.0
	 */
	private function insert_incompletion_postmeta( $object_id, $trigger = 'unspecified' ) {

		global $wpdb;

		// Add '_is_complete' to the user postmeta table for object.
		$user_metadatas = array(
			'_is_complete'        => 'no',
			'_completion_trigger' => $trigger,
		);

		foreach ( $user_metadatas as $key => $value ) {

			/**
			 * It's too difficult to keep track of multiple postmetas for each lesson incomplete
			 * Instead, I'm just replacing the old '_is_complete' value with 'no'
			 *
			 * Lessons that have never been complete will not have an '_is_complete' record,
			 * Lessons that were completed will have an '_is_complete' record of 'yes',
			 * Lessons that have been completed once but were marked incomplete will have an '_is_complete' record of 'no'
			 */
			$update = $wpdb->update(
				$wpdb->prefix . 'lifterlms_user_postmeta',
				array(
					'user_id'      => $this->get_id(),
					'post_id'      => $object_id,
					'meta_key'     => $key,
					'meta_value'   => $value,
					'updated_date' => current_time( 'mysql' ),
				),
				array(
					'user_id'  => $this->get_id(),
					'post_id'  => $object_id,
					'meta_key' => $key,
				),
				array( '%d', '%d', '%s', '%s', '%s' )
			); // db call ok; no-cache ok.

			if ( false === $update ) {

				return false;

			}
		}

		return true;
	}

	/**
	 * Add student postmeta data when lesson is favorited.
	 *
	 * @since 7.5.0
	 *
	 * @see LLMS_Student->mark_favorite()
	 *
	 * @param int $object_id WP Post ID of the object to mark/unmark as favorite.
	 * @return bool
	 */
	private function insert_favorite_postmeta( $object_id ) {

		$update = llms_update_user_postmeta( $this->get_id(), $object_id, '_favorite', true );

		// Returns boolean if postmeta update is successful.
		return is_array( $update ) ? false : true;
	}

	/**
	 * Remove student postmeta data when lesson is unfavorited.
	 *
	 * @since 7.5.0
	 *
	 * @param int $object_id WP Post ID of the object to mark/unmark as favorite.
	 * @return bool
	 */
	private function remove_favorite_postmeta( $object_id ) {

		$update = llms_delete_user_postmeta( $this->get_id(), $object_id, '_favorite', true );

		// Returns boolean if postmeta update is successful.
		return is_array( $update ) ? false : true;
	}

	/**
	 * Add student postmeta data for enrollment into a course or membership
	 *
	 * @param    int    $product_id   WP Post ID of the course or membership
	 * @param    string $trigger      String describing the reason for enrollment
	 * @return   boolean
	 * @since    2.2.3
	 * @version  3.21.0
	 */
	private function insert_enrollment_postmeta( $product_id, $trigger = 'unspecified' ) {

		// Add info to the user postmeta table.
		$user_metadatas = array(
			'_enrollment_trigger' => $trigger,
			'_start_date'         => 'yes',
			'_status'             => 'enrolled',
		);

		$update = llms_bulk_update_user_postmeta( $this->get_id(), $product_id, $user_metadatas, false );

		// Returns an array with errored keys or true on success.
		return is_array( $update ) ? false : true;
	}

	/**
	 * Remove student enrollment postmeta for a given product.
	 *
	 * @since 3.33.0
	 *
	 * @param int    $product_id WP Post ID of the course or membership.
	 * @param string $trigger    Optional. String the reason for enrollment. Default `null`
	 * @return bool Whether or not the enrollment records have been succesfully removed.
	 */
	private function delete_enrollment_postmeta( $product_id, $trigger = null ) {

		// Delete info from the user postmeta table.
		$user_metadatas = array(
			'_enrollment_trigger' => $trigger,
			'_start_date'         => null,
			'_status'             => null,
		);

		$delete = llms_bulk_delete_user_postmeta( $this->get_id(), $product_id, $user_metadatas );

		return is_array( $delete ) ? false : true;
	}

	/**
	 * Add a new status record to the user postmeta table for a specific product
	 *
	 * @param    int    $product_id   WP Post ID of the course or membership
	 * @param    string $status       string describing the new status
	 * @param    string $trigger  String describing the reason for enrollment (optional)
	 * @return   boolean
	 * @since    3.0.0
	 * @version  3.21.0
	 */
	private function insert_status_postmeta( $product_id, $status = '', $trigger = null ) {

		$update = llms_update_user_postmeta( $this->get_id(), $product_id, '_status', $status, false );

		if ( $update && $trigger ) {
			$update = llms_update_user_postmeta( $this->get_id(), $product_id, '_enrollment_trigger', $trigger, false );
		}

		return $update;
	}

	/**
	 * Determine if a student is enrolled in a Course or Membership.
	 *
	 * @see     llms_is_user_enrolled()
	 *
	 * @param   int|array $product_ids WP Post ID of a Course, Section, Lesson, or Membership or array of multiple IDs.
	 * @param   string    $relation    Comparator for enrollment check.
	 *                                     All = user must be enrolled in all $product_ids.
	 *                                     Any = user must be enrolled in at least one of the $product_ids.
	 * @param   bool      $use_cache  If true, returns cached data if available, if false will run a db query.
	 *
	 * @return  boolean
	 *
	 * @since   3.0.0
	 * @version 3.25.0
	 */
	public function is_enrolled( $product_ids = null, $relation = 'all', $use_cache = true ) {

		// Assume enrollment unless we find otherwise.
		$ret = true;

		// Allow a single product ID to be submitted (backwards compat).
		$product_ids = ! is_array( $product_ids ) ? array( $product_ids ) : $product_ids;

		foreach ( $product_ids as $id ) {

			$enrolled = ( 'enrolled' === strtolower( $this->get_enrollment_status( $id, $use_cache ) ) );

			// If use must be enrolled in all products and one is not enrolled: quit the loop & return false.
			if ( 'all' === $relation && ! $enrolled ) {
				$ret = false;
				break;

				// If user must be enrolled in any.
			} elseif ( 'any' === $relation ) {

				// If we find an enrollment: return true and quit the loop.
				if ( $enrolled ) {
					$ret = true;
					break;

					// If not switch return to false but keep looking.
				} else {
					$ret = false;
				}
			}
		}

		return apply_filters( 'llms_is_user_enrolled', $ret, $this, $product_ids, $relation, $use_cache );
	}

	/**
	 * Mark a lesson, section, course, or track complete for the given user.
	 *
	 * @param  int    $object_id    WP Post ID of the lesson, section, course, or track
	 * @param  string $object_type  object type [lesson|section|course|track]
	 * @param  string $trigger      String describing the reason for marking complete
	 * @return bool
	 *
	 * @see    llms_mark_complete() calls this function without having to instantiate the LLMS_Student class first
	 *
	 * @since    3.3.1
	 * @version  3.17.1
	 */
	public function mark_complete( $object_id, $object_type, $trigger = 'unspecified' ) {

		// Short circuit if it's already completed.
		if ( $this->is_complete( $object_id, $object_type ) ) {
			return true;
		}

		return $this->update_completion_status( 'complete', $object_id, $object_type, $trigger );
	}

	/**
	 * Mark a lesson, section, course, or track incomplete for the given user
	 * Gives an "_is_complete" value of "no" for the given object
	 *
	 * @param  int    $object_id    WP Post ID of the lesson, section, course, or track
	 * @param  string $object_type  object type [lesson|section|course|track]
	 * @param  string $trigger      String describing the reason for marking incomplete
	 * @return bool
	 *
	 * @see    llms_mark_incomplete() calls this function without having to instantiate the LLMS_Student class first
	 *
	 * @since    3.5.0
	 * @version  3.17.0
	 */
	public function mark_incomplete( $object_id, $object_type, $trigger = 'unspecified' ) {

		return $this->update_completion_status( 'incomplete', $object_id, $object_type, $trigger );
	}

	/**
	 * Remove a student from a membership level.
	 *
	 * @since 2.7
	 * @since 3.7.5 Unknown.
	 * @since 3.36.2 Added the $delete parameter, that will allow related courses enrollments data deletion.
	 *
	 * @param  int     $membership_id WP Post ID of the membership.
	 * @param  string  $status        Optional. Status to update the removal to. Default is `expired`.
	 * @param  boolean $delete        Optional. Status to update the removal to. Default is `false`.
	 * @return void
	 */
	private function remove_membership_level( $membership_id, $status = 'expired', $delete = false ) {

		// Remove the user from the membership level.
		$membership_levels = $this->get_membership_levels();
		$key               = array_search( $membership_id, $membership_levels );
		if ( false !== $key ) {
			unset( $membership_levels[ $key ] );
		}
		update_user_meta( $this->get_id(), '_llms_restricted_levels', $membership_levels );

		global $wpdb;
		// Locate all enrollments triggered by this membership level.
		$q = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT post_id FROM {$wpdb->prefix}lifterlms_user_postmeta WHERE user_id = %d AND meta_key = '_enrollment_trigger' AND meta_value = %s",
				array( $this->get_id(), 'membership_' . $membership_id )
			),
			'OBJECT_K'
		); // db call ok; no-cache ok.

		$courses = array_keys( $q );

		if ( $courses ) {

			// Loop through all the courses and update the enrollment status.
			foreach ( $courses  as $course_id ) {
				// See if they should continue to have access to this course via another membership's current auto-enroll settings.
				$membership_levels    = $this->get_membership_levels();
				$unenroll_from_course = true;

				foreach ( $membership_levels as $membership_level_id ) {
					if ( $membership_id === $membership_level_id ) {
						continue;
					}

					$membership         = new LLMS_Membership( $membership_level_id );
					$autoenroll_courses = $membership->get_auto_enroll_courses();

					if ( $autoenroll_courses ) {
						if ( in_array( $course_id, $autoenroll_courses ) ) {
							// Update the enrollment trigger to the membership that is keeping them enrolled in the course.
							llms_update_user_postmeta( $this->get_id(), $course_id, '_enrollment_trigger', 'membership_' . $membership_level_id, false );

							$unenroll_from_course = false;
							break;
						}
					}
				}

				if ( ! $unenroll_from_course ) {
					continue;
				}

				if ( ! $delete ) {
					$this->unenroll( $course_id, 'membership_' . $membership_id, $status );
				} else {
					$this->delete_enrollment( $course_id, 'membership_' . $membership_id );
				}
			}
		}
	}

	/**
	 * Remove a student from a LifterLMS course or membership
	 *
	 * @since 3.0.0
	 * @since 3.26.0 Unknown.
	 * @since 3.37.9 Update to accommodate custom post type enrollments added through new filters.
	 *               Marked action `llms_user_removed_from_membership_level` as deprecated, use `llms_user_removed_from_membership` instead.
	 * @since 6.0.0 Removed the deprecated `llms_user_removed_from_membership_level` action hook
	 *              and moved the call to `LLMS_Student::remove_membership_level()` to be before triggering the
	 *              `llms_user_removed_from_{$post_type}` action hook.
	 *
	 * @see llms_unenroll_student()
	 *
	 * @param  int    $product_id WordPress Post ID of the course or membership.
	 * @param  string $trigger    Only remove the student if the original enrollment trigger matches the submitted value.
	 *                            Passing `any` will remove regardless of enrollment trigger.
	 * @param  string $new_status the value to update the new status with after removal is complete.
	 * @return bool
	 */
	public function unenroll( $product_id, $trigger = 'any', $new_status = 'expired' ) {

		// Can only unenroll those that are a currently enrolled.
		if ( ! $this->is_enrolled( $product_id, 'all', false ) ) {
			return false;
		}

		// Assume we can't unenroll.
		$update = false;

		// If trigger is "any" we'll unenroll regardless of the trigger.
		if ( 'any' === $trigger ) {

			$update = true;

		} else {

			$enrollment_trigger = $this->get_enrollment_trigger( $product_id );

			// No enrollment trigger exists b/c pre 3.0.0 enrollment, unenroll the user as if it was an 'any' trigger.
			if ( ! $enrollment_trigger ) {

				/**
				 * This filter allows customization of enrollments created prior to version 3.0.0
				 *
				 * Prior to 3.0.0 enrollments did not track an enrollment trigger so any unenrollments
				 * performed on an enrollment in this state will automatically be unenrolled.
				 *
				 * Returning `false` will prevent unenrollments against enrollments which don't have
				 * an enrollment trigger.
				 *
				 * @since 3.0.0
				 *
				 * @param bool $allow_unenrollment If true, allows unenrollment, otherwise prevents unenrollment.
				 */
				$update = apply_filters( 'lifterlms_legacy_unenrollment_action', true );

			} elseif ( $enrollment_trigger === $trigger ) {

				$update = true;

			}
		}

		// Update if we can.
		if ( $update ) {

			// Update enrollment for the product.
			if ( $this->insert_status_postmeta( $product_id, $new_status ) ) {

				// Update the cache.
				$this->cache_set( sprintf( 'enrollment_status_%d', $product_id ), $new_status );
				$this->cache_delete( sprintf( 'date_enrolled_%d', $product_id ) );
				$this->cache_delete( sprintf( 'date_updated_%d', $product_id ) );

				$post_type = str_replace( 'llms_', '', get_post_type( $product_id ) );

				// Run legacy action and trigger cascading unenrollments for membership relationships.
				if ( 'membership' === $post_type ) {

					// Users should be unenrolled from all courses they accessed through this membership.
					$this->remove_membership_level( $product_id, $new_status );
				}

				/**
				 * Trigger an action immediately following user unenrollment
				 *
				 * The dynamic portion of this hook, `{$post_type}` corresponds to the post type of the
				 * `$product_id`. Note that any post type prefixed with `llms_` is stripped. For example
				 * when triggered by a membership (`llms_membership`) the hook will be `llms_user_removed_from_membership`.
				 *
				 * @since 3.37.9
				 *
				 * @param int    $user_id    WP_User ID of the student
				 * @param int    $product_id WP_Post ID of the product.
				 * @param string $trigger    Enrollment trigger.
				 * @param string $new_status New enrollment status of the student after the unenrollment has taken place.
				 */
				do_action( "llms_user_removed_from_{$post_type}", $this->get_id(), $product_id, $trigger, $new_status );

				return true;

			}
		}

		// Update was prevented.
		return false;
	}

	/**
	 * Delete a student enrollment.
	 *
	 * @since 3.33.0
	 * @since 3.36.2 Added logic to physically remove from the membership level and remove enrollments data on related products.
	 * @since 4.2.0 The `$enrollment_trigger` parameter was added to the `llms_user_enrollment_deleted` action hook.
	 *
	 * @see `llms_delete_student_enrollment()` calls this function without having to instantiate the LLMS_Student class first.
	 *
	 * @param int    $product_id WP Post ID of the course or membership.
	 * @param string $trigger    Optional. Only delete the student's enrollment if the original enrollment trigger matches the submitted value.
	 *                           "any" will remove regardless of enrollment trigger. Default "any".
	 * @return bool Whether or not the enrollment records have been successfully removed.
	 */
	public function delete_enrollment( $product_id, $trigger = 'any' ) {

		// Assume we can't delete the enrollment.
		$delete = false;

		// Get the stored trigger.
		$enrollment_trigger = $this->get_enrollment_trigger( $product_id );

		// Okay to delete if trigger is "any" or if it matches the stored enrollment trigger.
		if ( 'any' === $trigger || $enrollment_trigger === $trigger ) {

			$delete = true;

		} elseif ( ! $enrollment_trigger ) {

			/**
			 * Customize the behavior of enrollment deletion for "legacy" orders.
			 *
			 * These orders were created before version 3.0.0 when there was no stored
			 * enrollment trigger.
			 *
			 * By default, we'll automatically delete these enrollments regardless of trigger.
			 *
			 * @since 3.33.0
			 *
			 * @param boolean $delete Whether or not to delete the enrollment.
			 */
			$delete = apply_filters( 'lifterlms_legacy_delete_enrollment_action', true );

			// Ensure we have an `$enrollment_trigger` when firing the `llms_user_enrollment_deleted` hook.
			$enrollment_trigger = $trigger;

		}

		// Delete the enrollment.
		if ( $delete && $this->delete_enrollment_postmeta( $product_id ) ) {

			// Clean the cache.
			$this->cache_delete( sprintf( 'enrollment_status_%d', $product_id ) );
			$this->cache_delete( sprintf( 'date_enrolled_%d', $product_id ) );
			$this->cache_delete( sprintf( 'date_updated_%d', $product_id ) );

			if ( 'llms_membership' === get_post_type( $product_id ) ) {
				// Physically remove from the membership level & remove enrollments data on related products.
				$this->remove_membership_level( $product_id, '', true );
			}

			/**
			 * Fires after an user enrollment has been deleted.
			 *
			 * @since 3.33.0
			 * @since 4.2.0 The `$enrollment_trigger` parameter was added.
			 *
			 * @param int    $user_id            WP User ID.
			 * @param int    $product_id         WP Post ID of the course or membership.
			 * @param string $enrollment_trigger The enrollment trigger.
			 */
			do_action( 'llms_user_enrollment_deleted', $this->get_id(), $product_id, $enrollment_trigger );

			// Success.
			return true;

		}

		// Nothing was deleted.
		return false;
	}

	/**
	 * Update the completion status of a track, course, section, or lesson for the current student
	 *
	 * Cascades up to parents and clears progress caches for parents.
	 *
	 * Triggers actions for completion/incompletion.
	 *
	 * Inserts / updates necessary user postmeta data.
	 *
	 * @since 3.17.0
	 * @since 4.2.0 Use filterable functions to determine if the object is completable.
	 *              Added filter to allow customization of object parent data.
	 *
	 * @param string $status      New status to update to, either "complete" or "incomplete".
	 * @param int    $object_id   WP_Post ID of the object.
	 * @param string $object_type The type of object. A lesson, section, course, or course_track.
	 * @param string $trigger     String describing the reason for the status change.
	 * @return bool
	 */
	private function update_completion_status( $status, $object_id, $object_type, $trigger = 'unspecified' ) {

		$student_id = $this->get_id();

		/**
		 * Fires before a student's object completion status is updated.
		 *
		 * The dynamic portion of this hook, `$status`, refers to the new completion status of the object,
		 * either "complete" or "incomplete"
		 *
		 * @since Unknown
		 *
		 * @param int    $student_id  WP_User ID of the student.
		 * @param int    $object_id   WP_Post ID of the object.
		 * @param string $object_type The type of object. A lesson, section, course, or course_track.
		 * @param string $trigger     String describing the reason for the status change.
		 */
		do_action( "before_llms_mark_{$status}", $student_id, $object_id, $object_type, $trigger );

		// Retrieve an instance of the objec we're acting on.
		if ( in_array( $object_type, llms_get_completable_post_types(), true ) ) {
			$object = llms_get_post( $object_id );
		} elseif ( in_array( $object_type, llms_get_completable_taxonomies(), true ) ) {
			$object = get_term( $object_id, $object_type );
		} else {
			return false;
		}

		/**
		 * Lessons have binary completion (complete or incomplete).
		 *
		 * Other objects are dependent on their children's statuses. These other object types
		 * must check the combined progress of their children to see if it's complete / incomplete.
		 */
		$complete = ( 'lesson' === $object_type ) ? ( 'complete' === $status ) : ( 100 == $this->get_progress( $object_id, $object_type, false ) );

		// Get parent information.
		$parent_data = array(
			'ids'  => array(),
			'type' => false,
		);

		// Get the immediate parent so we can cascade up and maybe update the parent's status.
		switch ( $object_type ) {

			case 'lesson':
				$parent_data['ids']  = array( $object->get( 'parent_section' ) );
				$parent_data['type'] = 'section';
				break;

			case 'section':
				$parent_data['ids']  = array( $object->get( 'parent_course' ) );
				$parent_data['type'] = 'course';
				break;

			case 'course':
				$parent_data['ids']  = wp_list_pluck( $object->get_tracks(), 'term_id' );
				$parent_data['type'] = 'course_track';
				break;

		}

		/**
		 * Filter the parent data used to cascade object completion up to an object's parent(s).
		 *
		 * @since 4.2.0
		 *
		 * @param array  $parent_data {
		 *     Array of the object's parent information.
		 *
		 *     @type int[]  $ids  Object ids for the parent object(s).
		 *     @type string $type Object type (course, course_track, etc...).
		 * }
		 * @param object $object      The object. An `LLMS_Course`, for example.
		 * @param int    $ojbect_id   The object's ID.
		 * @param string $object_type The object's type.
		 */
		$parent_data = apply_filters( 'llms_mark_complete_parent_data', $parent_data, $object, $object_id, $object_type );

		// Reset the cached progress for any objects with children.
		if ( 'lesson' !== $object_type ) {
			$this->set( sprintf( '%1$s_%2$d_progress', $object_type, $object_id ), '' );
		}

		// Reset cache for all parents.
		if ( $parent_data['ids'] && $parent_data['type'] ) {
			foreach ( $parent_data['ids'] as $pid ) {
				$this->set( sprintf( '%1$s_%2$d_progress', $parent_data['type'], $pid ), '' );
			}
		}

		// Determine if an update should be made.
		$update = ( 'complete' === $status && $complete ) || ( 'incomplete' === $status && ! $complete );

		if ( $update ) {

			// Insert meta data.
			if ( 'complete' === $status ) {
				$this->insert_completion_postmeta( $object_id, $trigger );
			} elseif ( 'incomplete' === $status ) {
				$this->insert_incompletion_postmeta( $object_id, $trigger );
			}

			/**
			 * Hook that fires when a student's completion status is updated for any object.
			 *
			 * The dynamic portion of this hook, `$status`, refers to the new completion status of the object,
			 * either "complete" or "incomplete"
			 *
			 * @since Unknown
			 *
			 * @param int    $student_id  WP_User ID of the student.
			 * @param int    $object_id   WP_Post ID of the object.
			 * @param string $object_type The type of object. A lesson, section, course, or course_track.
			 * @param string $trigger     String describing the reason for the status change.
			 */
			do_action( "llms_mark_{$status}", $student_id, $object_id, $object_type, $trigger );

			/**
			 * Hook that fires when a student's completion status is updated for a specific object type.
			 *
			 * The dynamic portion of this hook, `$object_type` refers to the WP_Post post_type of the object
			 * which the student's completion status is being updated for.
			 *
			 * The dynamic portion of this hook, `$status`, refers to the new completion status of the object,
			 * either "complete" or "incomplete"
			 *
			 * @since Unknown
			 *
			 * @param int $student_id WP_User ID of the student.
			 * @param int $object_id  WP_Post ID of the object.
			 */
			do_action( "lifterlms_{$object_type}_{$status}d", $student_id, $object_id );

			// Cascade up for parents.
			if ( $parent_data['ids'] && $parent_data['type'] ) {
				foreach ( $parent_data['ids'] as $pid ) {
					$this->update_completion_status( $status, $pid, $parent_data['type'], $trigger );
				}
			}

			/**
			 * Hook that fires after a student's completion status for an object and it's parents have
			 * been updated.
			 *
			 * The dynamic portion of this hook, `$status`, refers to the new completion status of the object,
			 * either "complete" or "incomplete"
			 *
			 * @since Unknown
			 *
			 * @param int    $student_id  WP_User ID of the student.
			 * @param int    $object_id   WP_Post ID of the object.
			 * @param string $object_type The type of object. A lesson, section, course, or course_track.
			 * @param string $trigger     String describing the reason for the status change.
			 */
			do_action( "after_llms_mark_{$status}", $student_id, $object_id, $object_type, $trigger );

		}

		return $update;
	}

	/**
	 * Determine if the student has favorited a lesson.
	 *
	 * @since 7.5.0
	 *
	 * @param int    $object_id   WP Post ID of the object to mark/unmark as favorite.
	 * @param string $object_type The object type, currently only 'lesson'.
	 * @return bool
	 */
	public function is_favorite( $object_id, $object_type = 'lesson' ) {

		$query = new LLMS_Query_User_Postmeta(
			array(
				'types'                 => 'favorites',
				'include_post_children' => false,
				'user_id'               => $this->get_id(),
				'post_id'               => $object_id,
				'per_page'              => 1,
				'no_found_rows'         => true,
			)
		);

		$ret = $query->has_results();

		/**
		 * Filter object favorite boolean value prior to returning.
		 *
		 * The dynamic portion of this filter, `{$object_type}`, refers to the Lesson.
		 *
		 * @since 7.5.0
		 *
		 * @param array|false  $ret         Array of favorite data or `false` if no favorite is found.
		 * @param int          $object_id   WP Post ID of the object to mark/unmark as favorite.
		 * @param string       $object_type The object type, currently only 'lesson'.
		 * @param LLMS_Student $instance    The Student Instance
		 */
		return apply_filters( 'llms_is_' . $object_type . '_favorite', $ret, $object_id, $object_type, $this );
	}

	/**
	 * Mark a lesson favorite for the given user.
	 *
	 * @since 7.5.0
	 *
	 * @see llms_mark_favorite() calls this function without having to instantiate the LLMS_Student class first.
	 *
	 * @param int    $object_id   WP Post ID of the object to mark/unmark as favorite.
	 * @param string $object_type The object type, currently only 'lesson'.
	 * @return bool
	 */
	public function mark_favorite( $object_id, $object_type ) {

		// Short circuit if it's already favorited.
		if ( $this->is_favorite( $object_id, $object_type ) ) {
			return true;
		}

		return $this->update_favorite_status( 'favorite', $object_id, $object_type );
	}

	/**
	 * Mark a lesson unfavorite for the given user.
	 *
	 * @since 7.5.0
	 *
	 * @see llms_mark_unfavorite() calls this function without having to instantiate the LLMS_Student class first.
	 *
	 * @param int    $object_id   WP Post ID of the object to mark/unmark as favorite.
	 * @param string $object_type The object type, currently only 'lesson'.
	 * @return bool
	 */
	public function mark_unfavorite( $object_id, $object_type ) {

		// Short circuit if it's not favorited.
		if ( ! $this->is_favorite( $object_id, $object_type ) ) {
			return true;
		}

		return $this->update_favorite_status( 'unfavorite', $object_id, $object_type );
	}

	/**
	 * Triggers actions for favorite/unfavorite.
	 *
	 * Update the favorite status of a lesson for the current student.
	 * Inserts / updates necessary user postmeta data.
	 *
	 * @since 7.5.0 Use filterable functions to determine if the object can be marked favorite.
	 *
	 * @param string $status      New status to update to, either "favorite" or "unfavorite".
	 * @param int    $object_id   WP Post ID of the object to mark/unmark as favorite.
	 * @param string $object_type The object type, currently only 'lesson'.
	 * @return bool
	 */
	private function update_favorite_status( $status, $object_id, $object_type ) {

		$student_id = $this->get_id();

		/**
		 * Fires before a student's object favorite status is updated.
		 *
		 * The dynamic portion of this hook, `$status`, refers to the new completion status of the object,
		 * either "favorite" or "unfavorite".
		 *
		 * @since 7.5.0
		 *
		 * @param int    $student_id  WP_User ID of the student.
		 * @param int    $object_id   WP Post ID of the object to mark/unmark as favorite.
		 * @param string $object_type The object type, currently only 'lesson'.
		 */
		do_action( "before_llms_mark_{$status}", $student_id, $object_id, $object_type );

		// Insert / Remove meta data.
		if ( 'favorite' === $status ) {
			$this->insert_favorite_postmeta( $object_id );
		} elseif ( 'unfavorite' === $status ) {
			$this->remove_favorite_postmeta( $object_id );
		}

		/**
		 * Hook that fires when a student's favorite status is updated for any object.
		 *
		 * The dynamic portion of this hook, `$status`, refers to the new favorite status of the object,
		 * either "favorite" or "unfavorite".
		 *
		 * @since 7.5.0
		 *
		 * @param int    $student_id  WP_User ID of the student.
		 * @param int    $object_id   WP Post ID of the object to mark/unmark as favorite.
		 * @param string $object_type The object type, currently only 'lesson'.
		 */
		do_action( "llms_mark_{$status}", $student_id, $object_id, $object_type );

		/**
		 * Hook that fires when a student's favorite status is updated for a specific object type.
		 *
		 * The dynamic portion of this hook, `$object_type` refers to the WP_Post post_type of the object
		 * which the student's completion status is being updated for.
		 *
		 * The dynamic portion of this hook, `$status`, refers to the new completion status of the object,
		 * either "favorite" or "unfavorite".
		 *
		 * @since 7.5.0
		 *
		 * @param int $student_id WP_User ID of the student.
		 * @param int $object_id  WP_Post ID of the object.
		 */
		do_action( "lifterlms_{$object_type}_{$status}d", $student_id, $object_id );

		return true;
	}
}
