Source: includes/class-sensei-course-structure.php

<?php
/**
 * File containing the class Sensei_Course_Structure.
 *
 * @package sensei
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Contains methods for retrieving and saving a Sensei course's structure.
 *
 * @since 3.6.0
 */
class Sensei_Course_Structure {
	/**
	 * Course instances.
	 *
	 * @var self[]
	 */
	private static $instances = [];

	/**
	 * The course post ID.
	 *
	 * @var int
	 */
	private $course_id;

	/**
	 * Get an instance of this class for a course.
	 *
	 * @param int $course_id The course post ID.
	 *
	 * @return static
	 */
	public static function instance( int $course_id ): self {
		if ( ! isset( self::$instances[ $course_id ] ) ) {
			self::$instances[ $course_id ] = new static( $course_id );
		}

		return self::$instances[ $course_id ];
	}

	/**
	 * Sensei_Course_Structure constructor.
	 *
	 * @param int $course_id The course post ID.
	 */
	private function __construct( int $course_id ) {
		$this->course_id = $course_id;
	}

	/**
	 * Check whether the current user may save a structure for a course.
	 *
	 * Shared by the course structure REST controller and the `sensei/course-structure-set`
	 * ability. The structure is resolved the same way save() resolves it: every lesson id
	 * needs `edit_post`, every module term save() would update needs `edit_term` and every
	 * module term save() would delete needs `delete_term`. This limits teachers to their own
	 * content. A structure that does not resolve has nothing to protect: save() rejects it
	 * with the same error before writing anything.
	 *
	 * @since $$next-version$$
	 *
	 * @param int   $course_id     Course post ID.
	 * @param array $raw_structure Structure as passed to save().
	 *
	 * @return bool Whether the user can save the structure.
	 */
	public static function can_current_user_save( int $course_id, array $raw_structure ): bool {
		// get_post( 0 ) falls back to the global post, which must never stand in for a course id.
		if ( $course_id <= 0 ) {
			return false;
		}

		$course = get_post( $course_id );
		if ( ! $course instanceof WP_Post || 'course' !== $course->post_type ) {
			return false;
		}

		$post_type_object = get_post_type_object( 'course' );
		if ( ! $post_type_object || ! is_user_logged_in() || ! current_user_can( $post_type_object->cap->edit_post, $course->ID ) ) {
			return false;
		}

		$course_structure = self::instance( $course->ID );
		$structure        = $course_structure->sanitize_structure( $raw_structure );
		if ( is_wp_error( $structure ) ) {
			return true;
		}

		foreach ( $structure as $item ) {
			if ( 'lesson' === $item['type'] ) {
				if ( ! self::can_current_user_edit_lesson( $item ) ) {
					return false;
				}

				continue;
			}

			$module_writes = $course_structure->resolve_module_writes( $item );

			if ( $module_writes['update'] && ! current_user_can( 'edit_term', $module_writes['update'] ) ) {
				return false;
			}

			foreach ( $module_writes['delete'] as $module_id ) {
				if ( ! current_user_can( 'delete_term', $module_id ) ) {
					return false;
				}
			}

			foreach ( $item['lessons'] as $lesson_item ) {
				if ( ! self::can_current_user_edit_lesson( $lesson_item ) ) {
					return false;
				}
			}
		}

		return true;
	}

	/**
	 * Check whether the current user may edit the lesson a sanitized structure item refers to.
	 *
	 * @param array $item Sanitized lesson item.
	 *
	 * @return bool True when the lesson does not exist yet, or the user can edit it.
	 */
	private static function can_current_user_edit_lesson( array $item ): bool {
		return ! $item['id'] || current_user_can( 'edit_post', $item['id'] );
	}

	/**
	 * Get the course structure.
	 *
	 * @see Sensei_Course_Structure::prepare_lesson()
	 * @see Sensei_Course_Structure::prepare_module()
	 *
	 * @param string  $context  Context that structure is being retrieved for. Possible values: edit, view.
	 * @param boolean $no_cache Avoid query cache.
	 *
	 * @return array {
	 *     An array which has course structure information.
	 *
	 *     @type array Each element is an array with either module or lesson information as defined in prepare_lesson()
	 *                 and prepare_module().
	 * }
	 */
	public function get( $context = 'view', $no_cache = false ) {
		if ( $no_cache ) {
			add_filter( 'posts_where', [ $this, 'filter_no_cache_where' ] );
		}

		$context = in_array( $context, [ 'view', 'edit' ], true ) ? $context : 'view';

		$structure = [];

		$published_lessons_only = 'view' === $context && ! current_user_can( 'read_private_posts' );
		$post_status            = $published_lessons_only ? 'publish' : 'any';
		$no_module_lessons      = wp_list_pluck( Sensei()->modules->get_none_module_lessons( $this->course_id, $post_status ), 'ID' );
		$modules                = $this->get_modules();

		foreach ( $modules as $module_term ) {
			$module = $this->prepare_module( $module_term, $post_status );
			if ( ! empty( $module['lessons'] ) || 'edit' === $context ) {
				$structure[] = $module;
			}
		}

		foreach ( $no_module_lessons as $lesson_id ) {
			$lesson = get_post( $lesson_id );
			if ( ! $lesson ) {
				continue;
			}

			$structure[] = $this->prepare_lesson( $lesson );
		}

		if ( $no_cache ) {
			remove_filter( 'posts_where', [ $this, 'filter_no_cache_where' ] );
		}

		return $structure;
	}

	/**
	 * Filter where adding an extra condition to avoid cache.
	 *
	 * @access private
	 *
	 * @param string $where Current where.
	 *
	 * @return string Where with extra condition to avoid cache.
	 */
	public function filter_no_cache_where( $where ) {
		$time = time();

		return $where . ' AND ' . $time . ' = ' . $time;
	}

	/**
	 * Prepare the result for a module.
	 *
	 * @see Sensei_Course_Structure::prepare_lesson()
	 *
	 * @param WP_Term      $module_term        Module term.
	 * @param array|string $lesson_post_status Lesson post status(es).
	 *
	 * @return array {
	 *     An array which has module information.
	 *
	 *     @type string $type        The type of the array which is 'module'.
	 *     @type int    $id          The module term id.
	 *     @type string $title       The module name.
	 *     @type string $teacher     Name of the teacher of this module, gets populated only in admin panel for admins and if the teacher is not admin.
	 *     @type int    $teacherId   ID of the module author, used by block editor to detect change of teacher.
	 *     @type string $lastTitle   Used in the block editor for unchanged title state reference.
	 *     @type string $slug        Slug of the module, not empty only if the slug is custom.
	 *     @type string $description The module description.
	 *     @type array  $lessons     An array of the module lessons. See Sensei_Course_Structure::prepare_lesson().
	 * }
	 */
	private function prepare_module( WP_Term $module_term, $lesson_post_status ): array {
		$lessons      = $this->get_module_lessons( $module_term->term_id, $lesson_post_status );
		$author       = Sensei_Core_Modules::get_term_author( $module_term->slug );
		$default_slug = $this->get_module_slug( $module_term->name );
		$module       = [
			'type'        => 'module',
			'id'          => $module_term->term_id,
			'title'       => $module_term->name,
			'description' => $module_term->description,
			'teacher'     => $author instanceof WP_User && ! user_can( $author, 'manage_options' ) ? $author->display_name : '',
			'teacherId'   => $author instanceof WP_User ? $author->ID : 0,
			'lastTitle'   => $module_term->name,
			'slug'        => $module_term->slug === $default_slug ? '' : $module_term->slug,
			'lessons'     => [],
		];

		foreach ( $lessons as $lesson ) {
			$module['lessons'][] = $this->prepare_lesson( $lesson );
		}

		return $module;
	}

	/**
	 * Prepare the result for a lesson.
	 *
	 * @param WP_Post $lesson_post Lesson post object.
	 *
	 * @return array {
	 *     An array which has lesson information.
	 *
	 *     @type string $type  The type of the array which is 'lesson'.
	 *     @type int    $id    The lesson post id.
	 *     @type string $title The lesson title.
	 *     @type bool   $draft True if the lesson is a draft.
	 * }
	 */
	private function prepare_lesson( WP_Post $lesson_post ): array {
		return [
			'type'           => 'lesson',
			'id'             => $lesson_post->ID,
			'title'          => $lesson_post->post_title,
			'draft'          => 'draft' === $lesson_post->post_status,
			'preview'        => Sensei_Utils::is_preview_lesson( $lesson_post->ID ),
			'initialContent' => current_user_can( 'edit_lesson', $lesson_post->ID )
				? get_post_meta( $lesson_post->ID, '_initial_content', true )
				: '',
		];
	}

	/**
	 * Get the lessons for a module.
	 *
	 * @param int          $module_term_id     Term ID for the module.
	 * @param array|string $lesson_post_status Lesson post status(es).
	 *
	 * @return WP_Post[]
	 */
	private function get_module_lessons( int $module_term_id, $lesson_post_status ): array {
		$lessons_query = Sensei()->modules->get_lessons_query( $this->course_id, $module_term_id, $lesson_post_status );

		return $lessons_query instanceof WP_Query ? $lessons_query->posts : [];
	}

	/**
	 * Get module terms in the correct order.
	 *
	 * @return WP_Term[]
	 */
	private function get_modules(): array {
		$modules = Sensei()->modules->get_course_modules( $this->course_id );

		if ( is_wp_error( $modules ) ) {
			$modules = [];
		}

		return $modules;
	}

	/**
	 * Save a new course structure.
	 *
	 * @see Sensei_Course_Structure::get()
	 *
	 * @param array $raw_structure Course structure to save in its raw, un-sanitized form as returned by get().
	 *
	 * @return bool|WP_Error
	 */
	public function save( array $raw_structure ) {
		$structure = $this->sanitize_structure( $raw_structure );
		if ( is_wp_error( $structure ) ) {
			return $structure;
		}

		$current_structure                                 = $this->get( 'edit' );
		list( $current_lesson_ids, $current_module_ids,  ) = $this->flatten_structure( $current_structure );

		$lesson_ids   = [];
		$module_order = [];
		$lesson_order = [];
		foreach ( $structure as $item ) {
			if ( 'module' === $item['type'] ) {
				$save_module_result = $this->save_module( $item );
				if ( ! $save_module_result ) {
					return false;
				}

				list( $module_id, $module_lesson_ids ) = $save_module_result;

				$lesson_ids     = array_merge( $lesson_ids, $module_lesson_ids );
				$module_order[] = $module_id;

			} elseif ( 'lesson' === $item['type'] ) {
				$lesson_id = $this->save_lesson( $item );
				if ( ! $lesson_id ) {
					return false;
				}

				$lesson_ids[]   = $lesson_id;
				$lesson_order[] = $lesson_id;

				Sensei()->course_lessons_repository->set_lesson_order( $lesson_id, $this->course_id, count( $lesson_order ) );
			}
		}
		// Save the module association.
		$module_diff = array_diff( $current_module_ids, $module_order );
		if ( ! empty( $module_diff ) || count( $current_module_ids ) !== count( $module_order ) ) {
			wp_set_object_terms( $this->course_id, $module_order, 'module' );
		}

		// Save the module order.
		$this->save_module_order( $module_order );

		// Save the lesson order.
		Sensei()->course_lessons_repository->set_root_lesson_order( $this->course_id, $lesson_order );

		// Delete removed modules and lessons.
		$delete_lesson_ids = array_diff( $current_lesson_ids, $lesson_ids );
		foreach ( $delete_lesson_ids as $lesson_id ) {
			$this->clear_lesson_associations( $lesson_id );
		}

		return true;
	}

	/**
	 * Save a module item.
	 *
	 * @see Sensei_Course_Structure::prepare_module()
	 *
	 * @param array $item Item to save as returned from prepare_module().
	 *
	 * @return false|array[] {
	 *     If successful, we return this:
	 *
	 *     @type int   $0 $module_id  Saved module ID.
	 *     @type array $1 $lesson_ids All the lesson IDs from this module.
	 * }
	 */
	private function save_module( array $item ) {
		$module_writes = $this->resolve_module_writes( $item );

		if ( $module_writes['update'] ) {
			$update_item       = $item;
			$update_item['id'] = $module_writes['update'];
			$module_id         = $this->update_module( $update_item );
		} else {
			$module_id = $this->create_module( $item );
		}

		if ( ! $module_id ) {
			return false;
		}

		$lesson_ids = array();

		foreach ( $item['lessons'] as $lesson_item ) {
			$lesson_id = $this->save_lesson( $lesson_item, $module_id );
			if ( ! $lesson_id ) {
				return false;
			}

			Sensei()->course_lessons_repository->set_lesson_module( $lesson_id, $module_id );
			Sensei()->course_lessons_repository->set_lesson_module_order( $lesson_id, $module_id, count( $lesson_ids ) );
			Sensei()->course_lessons_repository->delete_lesson_order( $lesson_id, $this->course_id );

			$lesson_ids[] = $lesson_id;
		}

		if ( $item['id'] && $item['id'] !== $module_id ) {
			wp_remove_object_terms( $this->course_id, $item['id'], 'module' );
		}

		// can_current_user_save() refuses these up front; the check here covers callers that skipped it.
		foreach ( $module_writes['delete'] as $delete_module_id ) {
			if ( current_user_can( 'delete_term', $delete_module_id ) ) {
				Sensei()->modules->remove_if_unused( $delete_module_id );
			}
		}

		Sensei_Core_Modules::update_module_teacher_meta( $module_id, get_post( $this->course_id )->post_author );

		return [
			$module_id,
			$lesson_ids,
		];
	}

	/**
	 * Resolve which module terms saving a module item writes to.
	 *
	 * Both save_module() and can_current_user_save() read the answer from here, so the
	 * permission check covers exactly the terms the save touches. A module is updated when
	 * its id names a term whose slug matches the requested one, or when another term already
	 * exists for the requested slug or title and the course teacher; otherwise one is created.
	 * The term the id named is deleted when the save moves to a different term, and so is the
	 * module the editor's `lastTitle` names when the module carries a custom slug. The term
	 * being updated is never a deletion candidate.
	 *
	 * @param array $item Sanitized module item.
	 *
	 * @return array{update: int|null, delete: int[]} `update` is the term ID to update, or null to create a module;
	 *                                                `delete` lists the term IDs save_module() removes when they end up unused.
	 */
	private function resolve_module_writes( array $item ): array {
		$update = null;

		if ( $item['id'] || $item['slug'] ) {
			$term = $item['id'] ? get_term( $item['id'], 'module' ) : null;
			// Custom slug gets priority over conventional slug.
			$slug = $item['slug'] ?? $this->get_module_slug( $item['title'] );

			if ( $term instanceof WP_Term && $term->slug === $slug ) {
				$update = (int) $term->term_id;
			} else {
				$existing_module = $this->try_get_existing_module_by_slug_or_title_for_author( $slug, $item['title'] );
				$update          = $existing_module ? (int) $existing_module->term_id : null;
			}
		}

		$delete = array();

		if ( ! empty( $item['slug'] ) && ! empty( $item['lastTitle'] ) ) {
			// A custom slug leaves behind the module generated from the title when the teacher changed.
			$previous_term = get_term_by( 'slug', $this->get_module_slug( $item['lastTitle'] ), 'module' );
			if ( $previous_term instanceof WP_Term ) {
				$delete[] = (int) $previous_term->term_id;
			}
		}

		if ( $item['id'] && get_term( $item['id'], 'module' ) instanceof WP_Term ) {
			$delete[] = (int) $item['id'];
		}

		$delete = array_values(
			array_unique(
				array_filter(
					$delete,
					function ( $module_id ) use ( $update ) {
						return $module_id !== $update;
					}
				)
			)
		);

		return array(
			'update' => $update,
			'delete' => $delete,
		);
	}

	/**
	 * Attempt to find an existing module available to the user.
	 *
	 * @param string $module_name Module name.
	 *
	 * @return int|null Term ID if found.
	 */
	private function get_existing_module( string $module_name ) {
		$slug            = $this->get_module_slug( $module_name );
		$existing_module = get_term_by( 'slug', $slug, 'module' );

		if ( $existing_module ) {
			return (int) $existing_module->term_id;
		}

		return null;
	}

	/**
	 * Create a module.
	 *
	 * @param array $item Item to create.
	 *
	 * @return false|int
	 */
	private function create_module( array $item ) {
		$args = [
			'description' => $item['description'],
			'slug'        => $item['slug'] ?? $this->get_module_slug( $item['title'] ),
		];

		// wp_insert_term() unslashes its input; addslashes() is wp_slash() for a string.
		$create_result = wp_insert_term( addslashes( $item['title'] ), 'module', wp_slash( $args ) );
		if ( is_wp_error( $create_result ) ) {
			return false;
		}

		return (int) $create_result['term_id'];
	}

	/**
	 * Update an existing module.
	 *
	 * @param array $item Item to save.
	 *
	 * @return false|int
	 */
	private function update_module( array $item ) {
		$term = get_term( $item['id'], 'module' );

		$changed_args = [];
		if ( $term->name !== $item['title'] ) {
			$changed_args['name'] = $item['title'];
		}
		if ( $term->description !== $item['description'] ) {
			$changed_args['description'] = $item['description'];
		}
		if ( $item['slug'] && $term->slug !== $item['slug'] ) {
			$changed_args['slug'] = $item['slug'];
		}

		if ( ! empty( $changed_args ) ) {
			$change_result = wp_update_term(
				$item['id'],
				'module',
				wp_slash( $changed_args )
			);

			if ( is_wp_error( $change_result ) ) {
				return false;
			}
		}

		return $term->term_id;
	}

	/**
	 * Get module slug.
	 *
	 * @param string $title Module title.
	 *
	 * @return string Slug.
	 */
	private function get_module_slug( $title ) {
		$teacher_user_id = get_post( $this->course_id )->post_author;

		return user_can( $teacher_user_id, 'manage_options' )
			? sanitize_title( $title )
			: intval( $teacher_user_id ) . '-' . sanitize_title( $title );
	}

	/**
	 * Get a module's slug when it differs from the one Sensei derives from its title.
	 *
	 * This is the `slug` prepare_module() reports, so a structure saved without the editor's bookkeeping
	 * treats the module the way the editor would.
	 *
	 * @param WP_Term $module_term Module term.
	 *
	 * @return string|null The custom slug, or null when the module uses the default slug.
	 */
	private function get_custom_module_slug( WP_Term $module_term ): ?string {
		return $module_term->slug === $this->get_module_slug( $module_term->name ) ? null : $module_term->slug;
	}

	/**
	 * Save module order.
	 *
	 * @param array $module_order Module order to save.
	 */
	public function save_module_order( array $module_order ) {
		$current_module_order_raw = get_post_meta( $this->course_id, '_module_order', true );
		$current_module_order     = $current_module_order_raw ? array_map( 'intval', $current_module_order_raw ) : [];

		if (
			( $current_module_order || ! empty( $module_order ) )
			&& ( $current_module_order !== $module_order )
		) {
			if ( empty( $module_order ) ) {
				delete_post_meta( $this->course_id, '_module_order' );
			} else {
				update_post_meta( $this->course_id, '_module_order', array_map( 'strval', $module_order ) );
			}
		}
	}

	/**
	 * Save a lesson item.
	 *
	 * @see Sensei_Course_Structure::prepare_lesson()
	 *
	 * @param array $item      Item to save.
	 * @param int   $module_id Module ID.
	 *
	 * @return false|int
	 */
	private function save_lesson( array $item, ?int $module_id = null ) {
		if ( $item['id'] ) {
			$lesson_id = $this->update_lesson( $item );
		} else {
			$lesson_id = $this->create_lesson( $item );
		}

		if ( $lesson_id ) {
			if ( ! $module_id ) {
				Sensei()->course_lessons_repository->unset_lesson_module( $lesson_id );
			} else {
				Sensei()->course_lessons_repository->set_lesson_module( $lesson_id, $module_id );
			}
		}

		return $lesson_id;
	}

	/**
	 * Get initial content markup.
	 * This is the content that is shown to the user when they first view the lesson.
	 *
	 * @param string $text_content Text content.
	 *
	 * @return string Markup or empty.
	 */
	private function get_lesson_content_markup( $text_content ) {
		$markup = '';
		if ( $text_content ) {
			$markup = '<!-- wp:paragraph -->' . wpautop( $text_content ) . '<!-- /wp:paragraph -->';
		}

		return $markup;
	}

	/**
	 * Create a lesson.
	 *
	 * @param array $item Item to create.
	 *
	 * @return false|int
	 */
	private function create_lesson( array $item ) {
		$post_args = [
			'post_title'   => $item['title'],
			'post_type'    => 'lesson',
			'post_status'  => 'draft',
			'post_content' => $this->get_lesson_content_markup( $item['initialContent'] ?? '' ),
			'meta_input'   => [
				'_lesson_course'   => $this->course_id,
				'_new_post'        => true,
				'_initial_content' => $item['initialContent'],
			],
		];

		$lesson_id = wp_insert_post( wp_slash( $post_args ) );

		if ( ! $lesson_id ) {
			return false;
		}

		/**
		 * Fires after a lesson is created while saving the course structure.
		 *
		 * @since 4.20.1
		 *
		 * @hook sensei_course_structure_lesson_created
		 *
		 * @param {int} $lesson_id Lesson post ID.
		 * @param {int} $course_id Course post ID.
		 */
		do_action( 'sensei_course_structure_lesson_created', $lesson_id, $this->course_id );

		if ( ! $this->create_quiz( $lesson_id ) ) {
			// Reporting the lesson as created would leave it without a quiz for good: a retry with its id
			// takes the update path, which never creates one. Remove it so the retry recreates both.
			wp_delete_post( $lesson_id, true );

			return false;
		}

		return $lesson_id;
	}

	/**
	 * Create an empty quiz for the lesson.
	 *
	 * @param int $lesson_id Lesson ID to create quiz for.
	 *
	 * @return int|false Quiz ID, or false when the quiz could not be created.
	 */
	private function create_quiz( int $lesson_id ) {
		$lesson = get_post( $lesson_id );

		$post_args = [
			'post_content' => '',
			'post_status'  => $lesson->post_status,
			'post_title'   => $lesson->post_title,
			'post_type'    => 'quiz',
			'post_parent'  => $lesson_id,
			'meta_input'   => [
				'_quiz_lesson' => $lesson_id,
			],
		];

		$quiz_id = wp_insert_post( wp_slash( $post_args ) );
		if ( ! $quiz_id ) {
			return false;
		}

		update_post_meta( $lesson_id, '_lesson_quiz', $quiz_id );

		/**
		 * Fires after a quiz is created while saving the course structure.
		 *
		 * @since 4.20.1
		 *
		 * @deprecated 4.22.0 Use sensei_quiz_create instead.
		 *
		 * @hook sensei_course_structure_quiz_created
		 *
		 * @param {int} $quiz_id   Quiz post ID.
		 * @param {int} $lesson_id Course post ID.
		 */
		do_action_deprecated( 'sensei_course_structure_quiz_created', array( $quiz_id, $lesson_id ), '4.22.0', 'sensei_quiz_create' );

		/**
		 * Fires after a quiz is created while saving the course structure.
		 *
		 * @since 4.22.0
		 *
		 * @hook sensei_quiz_create
		 *
		 * @param {int} $quiz_id   Quiz post ID.
		 * @param {int} $lesson_id Course post ID.
		 */
		do_action( 'sensei_quiz_create', $quiz_id, $lesson_id );

		return $quiz_id;
	}

	/**
	 * Update an existing lesson.
	 *
	 * @param array $item Item to save.
	 *
	 * @return false|int
	 */
	private function update_lesson( array $item ) {
		$lesson = get_post( $item['id'] );
		if ( $lesson->post_title !== $item['title'] ) {
			$post_args = [
				'ID'         => $lesson->ID,
				'post_title' => $item['title'],
			];

			$update_result = wp_update_post( wp_slash( $post_args ) );
			if ( ! $update_result || is_wp_error( $update_result ) ) {
				return false;
			}
		}

		$current_course = (int) Sensei()->course_lessons_repository->get_course_id( $lesson->ID );
		if ( $this->course_id !== $current_course ) {
			$this->clear_lesson_associations( $lesson->ID );
			Sensei()->course_lessons_repository->attach_lesson( $lesson->ID, $this->course_id );
		}

		return $lesson->ID;
	}

	/**
	 * Clear any previous associations a lesson had with a course.
	 *
	 * @param int $lesson_id Lesson ID.
	 */
	private function clear_lesson_associations( int $lesson_id ) {
		Sensei()->course_lessons_repository->detach_lesson( $lesson_id );
		Sensei()->course_lessons_repository->delete_all_lesson_module_orders( $lesson_id );
		Sensei()->course_lessons_repository->unset_lesson_module( $lesson_id );
	}

	/**
	 * Parses the lesson IDs and module IDs from the structure.
	 *
	 * @see Sensei_Course_Structure::get()
	 *
	 * @param array $structure Structure to flatten as returned by get().
	 *
	 * @return array[] {
	 *     @type array $0 $lesson_ids    All the lesson IDs.
	 *     @type array $1 $module_ids    All the module IDs.
	 *     @type array $2 $module_titles All the module titles.
	 * }
	 */
	private function flatten_structure( array $structure ): array {
		$lesson_ids    = [];
		$module_ids    = [];
		$module_titles = [];

		foreach ( $structure as $item ) {
			if ( ! isset( $item['type'] ) ) {
				continue;
			}

			if ( 'module' === $item['type'] ) {
				if ( ! empty( $item['id'] ) ) {
					$module_ids[] = $item['id'];
				}

				if ( isset( $item['title'] ) ) {
					$module_titles[] = $item['title'];
				}

				if ( ! empty( $item['lessons'] ) ) {
					foreach ( $item['lessons'] as $lesson_item ) {
						if ( ! empty( $lesson_item['id'] ) ) {
							$lesson_ids[] = $lesson_item['id'];
						}
					}
				}
			} elseif ( 'lesson' === $item['type'] && ! empty( $item['id'] ) ) {
				$lesson_ids[] = $item['id'];
			}
		}

		return [
			$lesson_ids,
			$module_ids,
			$module_titles,
		];
	}

	/**
	 * Parse, validate, and sanitize the structure input.
	 *
	 * @see Sensei_Course_Structure::get()
	 *
	 * @param array $raw_structure Structure array as returned by get().
	 *
	 * @return WP_Error|array False if the input is invalid.
	 */
	private function sanitize_structure( array $raw_structure ) {
		list( $lesson_ids, $module_ids, $module_titles ) = $this->flatten_structure( $raw_structure );
		$module_titles                                   = array_filter( $module_titles );

		if (
			array_unique( $module_ids ) !== $module_ids
			|| array_unique( $lesson_ids ) !== $lesson_ids
		) {
			return new WP_Error(
				'sensei_course_structure_duplicate_items',
				__( 'Individual lesson or modules cannot appear multiple times in the same course.', 'sensei-lms' )
			);
		}

		if ( array_unique( $module_titles ) !== $module_titles ) {
			return new WP_Error(
				'sensei_course_structure_duplicate_module_title',
				__( 'Different modules cannot have the same name.', 'sensei-lms' )
			);
		}

		$structure = [];

		foreach ( $raw_structure as $raw_item ) {
			if ( ! is_array( $raw_item ) ) {
				return new WP_Error(
					'sensei_course_structure_invalid_item',
					__( 'Each item must be an array.', 'sensei-lms' )
				);
			}

			$item = $this->sanitize_item( $raw_item );
			if ( is_wp_error( $item ) ) {
				return $item;
			}

			$structure[] = $item;
		}

		return $structure;
	}

	/**
	 * Validate and sanitize input item of structure.
	 *
	 * @see Sensei_Course_Structure::prepare_lesson()
	 * @see Sensei_Course_Structure::prepare_module()
	 *
	 * @param array $raw_item Module or lesson as returned by prepare_lesson or prepare_module.
	 *
	 * @return array|WP_Error
	 */
	private function sanitize_item( array $raw_item ) {
		$validate = $this->validate_item_structure( $raw_item );
		if ( is_wp_error( $validate ) ) {
			return $validate;
		}

		$item = [
			'type'  => $raw_item['type'],
			'id'    => ! empty( $raw_item['id'] ) ? intval( $raw_item['id'] ) : null,
			'title' => trim( sanitize_text_field( $raw_item['title'] ) ),
		];

		if ( 'module' === $raw_item['type'] ) {
			$stored_term = null;

			if ( $item['id'] ) {
				$term = get_term( $item['id'], 'module' );
				// Because term may get deleted in some cases during the process of changing the course teacher
				// which takes place before saving structure.
				if ( ! $term && ! empty( $raw_item['lastTitle'] ) ) {
					// During the teacher changing process of courses, modules are created or fetched using the
					// existing title, not the one that is sent in the course structure save process.
					$term       = $this->get_existing_module( $raw_item['lastTitle'] );
					$item['id'] = $term;
				}
				if ( ! $term || is_wp_error( $term ) ) {
					return new WP_Error(
						'sensei_course_structure_missing_module',
						// translators: Placeholder is ID for module.
						sprintf( __( 'Module with id "%d" was not found', 'sensei-lms' ), $item['id'] )
					);
				}

				$stored_term = $term instanceof WP_Term ? $term : get_term( (int) $item['id'], 'module' );
			} else {
				// Attempt to find an existing module available to the user.
				$item['id'] = $this->get_existing_module( $item['title'] );
			}

			$item['description'] = isset( $raw_item['description'] ) ? trim( wp_kses_post( $raw_item['description'] ) ) : null;
			$item['slug']        = ! empty( $raw_item['slug'] ) ? trim( sanitize_text_field( $raw_item['slug'] ) ) : null;
			$item['lastTitle']   = ! empty( $raw_item['lastTitle'] ) ? trim( sanitize_text_field( $raw_item['lastTitle'] ) ) : null;
			$item['lessons']     = [];
			$teacher_id          = absint( $raw_item['teacherId'] ?? 0 );

			// A caller that names a module by id without the editor's bookkeeping keeps the stored custom slug
			// and teacher: otherwise save_module() would read the missing slug as a retitle and move the
			// module's lessons to whatever module the default slug names.
			if ( $stored_term instanceof WP_Term ) {
				if ( ! $item['slug'] ) {
					$item['slug'] = $this->get_custom_module_slug( $stored_term );
				}
				if ( ! $teacher_id ) {
					$term_author = Sensei_Core_Modules::get_term_author( $stored_term->slug );
					$teacher_id  = $term_author instanceof WP_User ? (int) $term_author->ID : 0;
				}
			}

			if ( $item['slug'] ) {
				$in_use_in_other_course = Sensei_Teacher::is_module_in_use_by_different_course_and_teacher( $item['slug'], $this->course_id, $teacher_id );
				if ( $in_use_in_other_course ) {
					return new WP_Error(
						'module_in_use_in_different_course_by_different_teacher',
						sprintf(
						/* translators: Placeholder is the module slug. */
							__( 'Slug %s exists and is being used in another course.', 'sensei-lms' ),
							$item['slug']
						)
					);
				}

				$term_author = Sensei_Core_Modules::get_term_author( $item['slug'] );
				if ( $term_author instanceof WP_User &&
					wp_get_current_user()->ID !== $term_author->ID &&
					! current_user_can( 'manage_options' ) &&
					get_term_by( 'slug', $item['slug'], 'module' )
				) {
					return new WP_Error(
						'module_belongs_to_another_user',
						sprintf(
						/* translators: Placeholder is the module slug. */
							__( 'A module with the slug %s is already owned by another teacher.', 'sensei-lms' ),
							$item['slug']
						)
					);
				}
			}

			foreach ( $raw_item['lessons'] as $raw_lesson ) {
				$lesson = $this->sanitize_item( $raw_lesson );
				if ( is_wp_error( $lesson ) ) {
					return $lesson;
				}

				if ( 'lesson' !== $lesson['type'] ) {
					return new WP_Error(
						'sensei_course_structure_invalid_module_lesson',
						__( 'Module lessons array can only contain lessons.', 'sensei-lms' )
					);
				}

				$item['lessons'][] = $lesson;
			}
		} elseif ( 'lesson' === $raw_item['type'] ) {
			if ( $item['id'] ) {
				$lesson = get_post( $item['id'] );
				if ( ! $lesson || in_array( $lesson->post_status, [ 'trash', 'auto-draft' ], true ) || 'lesson' !== $lesson->post_type ) {
					return new WP_Error(
						'sensei_course_structure_missing_lesson',
						// translators: Placeholder is ID for lesson.
						sprintf( __( 'Lesson with id "%d" was not found', 'sensei-lms' ), $item['id'] )
					);
				}
			}
			$item['initialContent'] = ! empty( $raw_item['initialContent'] ) ? trim( wp_kses_post( $raw_item['initialContent'] ) ) : null;
		}

		return $item;
	}

	/**
	 * Validate item is build correctly.
	 *
	 * @see Sensei_Course_Structure::prepare_lesson()
	 * @see Sensei_Course_Structure::prepare_module()
	 *
	 * @param array $raw_item Raw item to sanitize.
	 *
	 * @return true|WP_Error
	 */
	private function validate_item_structure( array $raw_item ) {
		if ( ! isset( $raw_item['type'] ) || ! in_array( $raw_item['type'], [ 'module', 'lesson' ], true ) ) {
			return new WP_Error(
				'sensei_course_structure_invalid_item_type',
				__( 'All items must have a `type` set.', 'sensei-lms' )
			);
		}

		if ( ! isset( $raw_item['title'] ) || '' === trim( sanitize_text_field( $raw_item['title'] ) ) ) {
			if ( 'module' === $raw_item['type'] ) {
				return new WP_Error(
					'sensei_course_structure_modules_missing_title',
					__( 'Please ensure all modules have a name before saving.', 'sensei-lms' )
				);
			}

			return new WP_Error(
				'sensei_course_structure_lessons_missing_title',
				__( 'Please ensure all lessons have a name before saving.', 'sensei-lms' )
			);
		}

		if (
			'module' === $raw_item['type']
			&& (
				! isset( $raw_item['lessons'] )
				|| ! is_array( $raw_item['lessons'] )
			)
		) {
			return new WP_Error(
				'sensei_course_structure_missing_lessons',
				__( 'Module items must include a `lessons` array.', 'sensei-lms' )
			);
		}

		return true;
	}

	/**
	 * Sort structure.
	 *
	 * @param array  $structure Structure to be sorted.
	 * @param int[]  $order     Order to sort the lessons.
	 * @param string $type      Type to be sorted (lesson or module).
	 *
	 * @return array Sorted structure.
	 */
	public static function sort_structure( $structure, $order, $type ) {
		if ( ! empty( $order )
		&& [ 0 ] !== $order ) {
			usort(
				$structure,
				function ( $a, $b ) use ( $order, $type ) {
					// One of the types is not being sorted.
					if ( $type !== $a['type'] || $type !== $b['type'] ) {
						// If types are equal, keep in the current positions.
						if ( $a['type'] === $b['type'] ) {
							return 0;
						}

						// Always keep the modules before the lessons.
						return 'module' === $a['type'] ? - 1 : 1;
					}

					$a_position = array_search( $a['id'], $order, true );
					$b_position = array_search( $b['id'], $order, true );

					// If both weren't sorted, keep the current positions.
					if ( false === $a_position && false === $b_position ) {
						return 0;
					}

					// Keep not sorted items in the end.
					if ( false === $a_position ) {
						return 1;
					}

					return false === $b_position || $a_position < $b_position ? - 1 : 1;
				}
			);
		}

		return $structure;
	}

	/**
	 * Get first incomplete lesson ID or `false` if there is none.
	 *
	 * @return int|false
	 */
	public function get_first_incomplete_lesson_id() {
		list( $lesson_ids, ) = $this->flatten_structure( $this->get() );
		foreach ( $lesson_ids as $lesson_id ) {
			if ( ! Sensei_Utils::user_completed_lesson( $lesson_id ) ) {
				return $lesson_id;
			}
		}

		return false;
	}

	/**
	 * Try getting an existing module by custom slug, default slug, or name for course teacher.
	 *
	 * @since 4.6.0
	 *
	 * @param string $slug  The slug of the module.
	 * @param string $title The title of the module.
	 *
	 * @return WP_Term|null The module if it exists, null otherwise.
	 */
	private function try_get_existing_module_by_slug_or_title_for_author( $slug, $title ) {
		$default_slug = $this->get_module_slug( $title );

		// First try to get an existing module directly by the provided slug.
		$existing_module = get_term_by( 'slug', $slug, 'module' );

		// If not found, try to find if this teacher already has a module for this, with the default slug.
		if ( ! $existing_module && $slug !== $default_slug ) {
			$existing_module = get_term_by( 'slug', $default_slug, 'module' );
		}

		$author = get_post( $this->course_id )->post_author;
		// If not found, try to find module for this teacher using term-meta.
		if ( ! $existing_module && ! user_can( $author, 'manage_options' ) ) {
			$modules = get_terms(
				array(
					'taxonomy'   => 'module',
					'name'       => $title,
					'hide_empty' => false,
					'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Only executed while saving, not too many data possibly.
						array(
							'key'     => 'module_author',
							'value'   => $author,
							'compare' => '=',
						),
					),
				)
			);
			if ( ! empty( $modules ) ) {
				$existing_module = $modules[0];
			}
		}
		return $existing_module;
	}
}