<?php
/**
 * LifterLMS Membership Model
 *
 * @package LifterLMS/Models/Classes
 *
 * @since 3.0.0
 * @version 6.0.0
 */

defined( 'ABSPATH' ) || exit;

/**
 * LLMS_Membership model class
 *
 * @since 3.0.0
 * @since 3.30.0 Added optional argument to `add_auto_enroll_courses()` method.
 * @since 3.32.0 Added `get_student_count()` method.
 * @since 3.36.3 Added `get_categories()`, `get_tags()` and `toArrayAfter()` methods.
 * @since 3.38.1 Added methods for retrieving posts associated with the membership.
 * @since 4.0.0 Added MySQL 8.0 compatibility.
 * @since 5.2.1 Check for an empty sales page URL or ID.
 * @since 5.3.0 Move sales page methods to `LLMS_Trait_Sales_Page`.
 *
 * @property string $audio_embed                URL to an oEmbed enable audio URL.
 * @property int[]  $auto_enroll                Array of course IDs that users will be autoenrolled in upon successful enrollment in this membership.
 * @property array  $instructors                Course instructor user information.
 * @property string $restriction_redirect_type  What type of redirect action to take when content is restricted by this membership [none|membership|page|custom].
 * @property int    $redirect_page_id           WP Post ID of a page to redirect users to when $restriction_redirect_type is 'page'.
 * @property string $redirect_custom_url        Arbitrary URL to redirect users to when $restriction_redirect_type is 'custom'.
 * @property string $restriction_add_notice     Whether or not to add an on screen message when content is restricted by this membership [yes|no].
 * @property string $restriction_notice         Notice to display when $restriction_add_notice is 'yes'.
 * @property string $featured_pricing           User defined additional pricing information.
 * @property int    $sales_page_content_page_id WP Post ID of the WP page to redirect to when $sales_page_content_type is 'page'.
 * @property string $sales_page_content_type    Sales page behavior [none,content,page,url].
 * @property string $sales_page_content_url     Redirect URL for a sales page, when $sales_page_content_type is 'url'.
 * @property string $video_embed                URL to an oEmbed enable video URL.
 */
class LLMS_Membership extends LLMS_Post_Model implements LLMS_Interface_Post_Instructors {

	use LLMS_Trait_Audio_Video_Embed;
	use LLMS_Trait_Sales_Page;

	/**
	 * Membership post meta.
	 *
	 * @var array
	 */
	protected $properties = array(
		'auto_enroll'               => 'array',
		'instructors'               => 'array',
		'redirect_page_id'          => 'absint',
		'restriction_add_notice'    => 'yesno',
		'restriction_notice'        => 'html',
		'restriction_redirect_type' => 'text',
		'redirect_custom_url'       => 'text',
		'featured_pricing'          => 'html',
		'tile_featured_video'       => 'yesno',
	);

	/**
	 * Database post type.
	 *
	 * @var string
	 */
	protected $db_post_type = 'llms_membership';

	/**
	 * Model name.
	 *
	 * @var string
	 */
	protected $model_post_type = 'membership';

	/**
	 * Constructor for this class and the traits it uses.
	 *
	 * @since 5.3.0
	 *
	 * @param string|int|LLMS_Post_Model|WP_Post $model 'new', WP post id, instance of an extending class, instance of WP_Post.
	 * @param array                              $args  Args to create the post, only applies when $model is 'new'.
	 */
	public function __construct( $model, $args = array() ) {

		$this->construct_audio_video_embed();
		$this->construct_sales_page();
		parent::__construct( $model, $args );
	}

	/**
	 * Add courses to autoenrollment by id
	 *
	 * @since 3.0.0
	 * @since 3.30.0 Added optional `$replace` argument.
	 *
	 * @param array|int $course_ids Array of course id or course id as int.
	 * @param bool      $replace    Optional. When `true`, replaces all existing courses with `$course_ids`, when false merges `$course_ids` with existing courses. Default `false`.
	 * @return boolean Returns `true` on success, and `false` on error or if the value in the db is unchanged.
	 */
	public function add_auto_enroll_courses( $course_ids, $replace = false ) {

		// allow a single course_id to be passed in.
		if ( ! is_array( $course_ids ) ) {
			$course_ids = array( $course_ids );
		}

		// add existing courses to the array if replace is false.
		if ( ! $replace ) {
			$course_ids = array_merge( $course_ids, $this->get_auto_enroll_courses() );
		}

		return $this->set( 'auto_enroll', array_unique( $course_ids ) );
	}

	/**
	 * Retrieve a list of posts associated with the membership
	 *
	 * An associated post is:
	 * + A post, page, or custom post type which supports `llms-membership-restrictions` and has restrictions enabled to this membership
	 * + A course that exists in the memberships list of auto-enroll courses
	 * + A course that has at least one access plan with members-only availability linked to this membership
	 *
	 * @since 3.38.1
	 * @since 4.15.0 Minor restructuring to only query post type data when it's needed.
	 *
	 * @param string $post_type If supplied, returns only associations of this post type, otherwise returns an associative array of all associations.
	 * @return array[]|int[] An array of arrays of post IDs. The array keys are the post type and the array values are arrays of integers.
	 *                       If `$post_type` is supplied returns an array of associated post ids as integers.
	 */
	public function get_associated_posts( $post_type = null ) {

		// If we're querying only posts, we can skip these associations entirely because courses don't support them.
		$post_types = 'course' !== $post_type ? get_post_types_by_support( 'llms-membership-restrictions' ) : array();

		// If we're looking at a single post type we only have to query associations for that post type.
		$post_types = $post_type ? array_intersect( $post_types, array( $post_type ) ) : $post_types;

		// Our return array.
		$posts = array();

		// Retrieve all posts that are restricted to a membership via a LifterLMS Membership Restriction setting.
		foreach ( $post_types as $type ) {
			$posts[ $type ] = $this->query_associated_posts( $type, '_llms_is_restricted', 'yes', '_llms_restricted_levels' );
		}

		// Include courses if courses were requested or if no specific post type was requested.
		if ( ! $post_type || 'course' === $post_type ) {
			$posts['course'] = $this->query_associated_courses();
		}

		/**
		 * Filter the list of posts associated with the membership.
		 *
		 * @since 3.38.1
		 *
		 * @param array[]         $posts     An array of arrays of post IDs. The array keys are the post type and the array values are arrays of integers.
		 * @param string|null     $post_type The requested post type if only a specific post type was requested, otherwise `null` to indicate all associated post types.
		 * @param LLMS_Membership $this      Membership object.
		 */
		$posts = apply_filters( 'llms_membership_get_associated_posts', $posts, $post_type, $this );

		// If a single post type was requested, return only that.
		if ( $post_type ) {
			// Return the request post type array and fallback to an empty array if that post type doesn't exist.
			return isset( $posts[ $post_type ] ) ? $posts[ $post_type ] : array();
		}

		// Remove empty arrays and return the rest.
		return array_filter( $posts );
	}

	/**
	 * Get an array of the auto enrollment course ids
	 *
	 * Uses a custom function due to the default "get_array" returning an array with an empty string
	 *
	 * @since 3.0.0
	 * @since 4.15.0 Exclude unpublished courses from the return array.
	 *
	 * @return array
	 */
	public function get_auto_enroll_courses() {

		// Ensure an array when metadata is not set.
		$courses = isset( $this->auto_enroll ) ? $this->get( 'auto_enroll' ) : array();

		// Exclude unpublished courses.
		$courses = array_values(
			array_filter(
				$courses,
				function ( $id ) {
					return 'publish' === get_post_status( $id );
				}
			)
		);

		/**
		 * Filters the list of the membership's auto enroll courses
		 *
		 * @since 3.0.0
		 *
		 * @param int[]           $courses    List of LLMS_Course IDs.
		 * @param LLMS_Membership $membership Membership post object.
		 */
		return apply_filters( 'llms_membership_get_auto_enroll_courses', $courses, $this );
	}

	/**
	 * Retrieve membership categories.
	 *
	 * @since 3.36.3
	 *
	 * @param array $args Array of args passed to `wp_get_post_terms()`.
	 * @return array
	 */
	public function get_categories( $args = array() ) {
		return wp_get_post_terms( $this->get( 'id' ), 'membership_cat', $args );
	}

	/**
	 * Retrieve course instructor information
	 *
	 * @since 3.13.0
	 *
	 * @param boolean $exclude_hidden If true, excludes hidden instructors from the return array.
	 * @return array
	 */
	public function get_instructors( $exclude_hidden = false ) {

		return apply_filters(
			'llms_membership_get_instructors',
			$this->instructors()->get_instructors( $exclude_hidden ),
			$this,
			$exclude_hidden
		);
	}

	/**
	 * Retrieve an instance of the LLMS_Product for this course
	 *
	 * @since 3.3.0
	 * @return LLMS_Product
	 */
	public function get_product() {
		return new LLMS_Product( $this->get( 'id' ) );
	}

	/**
	 * Retrieve the number of enrolled students in the membership.
	 *
	 * @since 3.32.0
	 * @since 6.0.0 Don't access `LLMS_Student_Query` properties directly.
	 *
	 * @return int
	 */
	public function get_student_count() {

		$query = new LLMS_Student_Query(
			array(
				'post_id'    => $this->get( 'id' ),
				'statuses'   => array( 'enrolled' ),
				'count_only' => true,
				'sort'       => array( 'id' => 'ASC' ),
			)
		);

		return $query->get_count_only_result();
	}

	/**
	 * Get an array of student IDs based on enrollment status in the membership
	 *
	 * @since 3.0.0
	 *
	 * @param string|string[] $statuses Optional. List of enrollment statuses to query by status query is an OR relationship. Default is 'enrolled'.
	 * @param int             $limit    Optional. Number of results. Default is `50`.
	 * @param int             $skip     Optional. Number of results to skip (for pagination). Default is `0`.
	 * @return array
	 */
	public function get_students( $statuses = 'enrolled', $limit = 50, $skip = 0 ) {
		return llms_get_enrolled_students( $this->get( 'id' ), $statuses, $limit, $skip );
	}

	/**
	 * Retrieve membership tags.
	 *
	 * @since 3.36.3
	 *
	 * @param array $args Array of args passed to `wp_get_post_terms()`.
	 * @return array
	 */
	public function get_tags( $args = array() ) {
		return wp_get_post_terms( $this->get( 'id' ), 'membership_tag', $args );
	}

	/**
	 * Retrieve an instance of the Post Instructors model
	 *
	 * @since 3.13.0
	 *
	 * @return LLMS_Post_Instructors
	 */
	public function instructors() {
		return new LLMS_Post_Instructors( $this );
	}

	/**
	 * Retrieve courses associated with the membership
	 *
	 * @since 3.38.1
	 * @since 4.15.0 Exclude unpublished courses.
	 *
	 * @see LLMS_Membership::get_associated_posts()
	 *
	 * @return int[]
	 */
	protected function query_associated_courses() {

		// Start with autoenroll courses.
		$courses = $this->get_auto_enroll_courses();

		// Retrieve all access plans with a members-only availability restriction for this membership.
		foreach ( $this->query_associated_posts( 'llms_access_plan', '_llms_availability', 'members', '_llms_availability_restrictions' ) as $plan_id ) {
			$plan = llms_get_post( $plan_id );
			if ( $plan ) {
				$id = $plan->get( 'product_id' );
				if ( 'publish' === get_post_status( $id ) ) {
					$courses[] = $id;
				}
			}
		}

		return array_unique( $courses );
	}

	/**
	 * Performs a WPDB query to retrieve posts associated with the membership
	 *
	 * @since 3.38.1
	 * @since 4.0.0 Escape `{` character in SQL query to add MySQL 8.0 support.
	 *
	 * @see LLMS_Membesrhip::get_associated_posts()
	 *
	 * @param string $post_type     Post type to query for an association with.
	 * @param string $enabled_key   A meta key name, used to check if the association is enabled for the associated post. For example: "_llms_is_restricted"
	 * @param string $enabled_value The meta value of the `$enabled_key` when the association is enabled. For example "yes" when checking "_llms_is_restricted"..
	 * @param string $list_key      The meta key name where associations are stored as a serialized array of WP_Post IDs. For example "_llms_restricted_levels".
	 * @return int[]
	 */
	protected function query_associated_posts( $post_type, $enabled_key, $enabled_value, $list_key ) {

		global $wpdb;

		// See if we have a cached result first.
		$cache = sprintf( 'membership_%1$d_associated_%2$s', $this->get( 'id' ), $post_type );
		$found = null;
		$ids   = wp_cache_get( $cache, '', false, $found );

		// We don't, perform a query.
		if ( ! $found ) {

			$ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
				$wpdb->prepare(
					"SELECT metas.post_id
				 FROM {$wpdb->postmeta} AS metas
				 JOIN {$wpdb->postmeta} AS metas2 ON metas2.post_id = metas.post_id
				 JOIN {$wpdb->posts} AS posts ON posts.ID = metas.post_id
				 WHERE 1
				   AND posts.post_status = 'publish'
				   AND posts.post_type = %s
				   AND metas2.meta_key = %s
				   AND metas2.meta_value = %s
				   AND metas.meta_key = %s
				   AND metas.meta_value REGEXP %s;",
					$post_type,
					$enabled_key,
					$enabled_value,
					$list_key,
					'a:[0-9][0-9]*:\{(i:[0-9][0-9]*;(i|s:[0-9][0-9]*):"?[0-9][0-9]*"?;)*(i:[0-9][0-9]*;(i|s:[0-9][0-9]*):"?' . $this->get( 'id' ) . '"?;)'
				)
			);

			// Only return ints.
			$ids = array_map( 'absint', $ids );

			// Cache the result.
			wp_cache_set( $cache, $ids );

		}

		return $ids;
	}

	/**
	 * Remove a course from auto enrollment
	 *
	 * @since 3.0.0
	 *
	 * @param int $course_id WP_Post ID of the course.
	 * @return bool
	 */
	public function remove_auto_enroll_course( $course_id ) {
		return $this->set( 'auto_enroll', array_diff( $this->get_auto_enroll_courses(), array( $course_id ) ) );
	}

	/**
	 * Save instructor information
	 *
	 * @since 3.13.0
	 *
	 * @param array $instructors Array of course instructor information.
	 * @return array
	 */
	public function set_instructors( $instructors = array() ) {

		return $this->instructors()->set_instructors( $instructors );
	}

	/**
	 * Add data to the membership model when converted to array.
	 *
	 * Called before data is sorted and returned by `$this->jsonSerialize()`.
	 *
	 * @since 3.36.3
	 *
	 * @param array $arr Data to be serialized.
	 * @return array
	 */
	public function toArrayAfter( $arr ) {
		$arr['categories'] = $this->get_categories(
			array(
				'fields' => 'names',
			)
		);

		$arr['tags'] = $this->get_tags(
			array(
				'fields' => 'names',
			)
		);

		return $arr;
	}
}
