<?php
/**
 * Processor: Course Data
 *
 * @package LifterLMS/Processors/Classes
 *
 * @since 3.15.0
 * @version 6.0.0
 */

defined( 'ABSPATH' ) || exit;

/**
 * LLMS_Processor_Course_Data
 *
 * Handle background processing of average progress & average grade for courses.
 *
 * The background process calculates "expensive" aggregate course data and stores them
 * on the `wp_postmeta` table so the data can be access later with a single
 * database read.
 *
 * The process is queued for recalculation when:
 *
 *   + Students enroll.
 *   + Students unenroll.
 *   + Students complete lessons.
 *   + Students complete quizzes.
 *
 * Upon completion, the following values can be accessed via the `LLMS_Course` model
 * to retrieve the aggregate data for the course:
 *
 *   + Average grade: `LLMS_Course::get( 'average_grade' )`
 *   + Average progress: `LLMS_Course::get( 'average_progress' )`
 *   + Number of currently enrolled students: `LLMS_Course::get( 'enrolled_students' )`
 *
 * @since 3.15.0
 * @since 4.12.0 Remove (protected) method `LLMS_Processor_Course_Data::complete()`, the override of the parent method is no longer needed.
 */
class LLMS_Processor_Course_Data extends LLMS_Abstract_Processor {

	/**
	 * Unique identifier for the processor
	 *
	 * @var string
	 */
	protected $id = 'course_data';

	/**
	 * WP Cron Hook for scheduling the bg process
	 *
	 * @var string
	 */
	private $schedule_hook = 'llms_calculate_course_data';

	/**
	 * Maximum number of students allowed in a course
	 *
	 * When enrollment is higher than this number
	 * throttling the calculations will be delayed.
	 *
	 * @var int
	 */
	private $throttle_max_students;

	/**
	 * Frequency of calculation process when the process is throttled.
	 *
	 * @var int
	 */
	private $throttle_frequency;

	/**
	 * Action triggered to queue queries needed to make the calculation
	 *
	 * @since 3.15.0
	 * @since 4.12.0 Add throttling by course in progress and adjust last_run calculation to be specific to the course.
	 *               Improve performance of the student query by removing unneeded sort columns.
	 * @since 4.21.0 When there's no students found in the course, run the `task_complete()` method to ensure data
	 *               from a previous calculation is cleared.
	 * @since 6.0.0 Don't access `LLMS_Student_Query` properties directly.
	 *
	 * @param int $course_id WP Post ID of the course.
	 * @return void|null
	 */
	public function dispatch_calc( $course_id ) {
		$this->log( sprintf( 'Course data calculation dispatched for course %d.', $course_id ) );

		// Make sure we have a course.
		$course = llms_get_post( $course_id );
		if ( ! $course instanceof LLMS_Course ) {
			return null;
		}

		// Return early if we're already processing data for the given course.
		if ( $this->is_already_processing_course( $course_id ) ) {
			return $this->dispatch_calc_throttled( $course_id );
		}

		// Retrieve args.
		$args = $this->get_student_query_args( $course_id );

		// Get the results for the current set of arguments.
		$query = new LLMS_Student_Query( $args );

		// No students in the course, run task completion.
		if ( ! $query->get_number_results() ) {
			return $this->task_complete( $course, $this->get_task_data(), true );
		}

		// Get the total number of students as a separate query since deprecating SQL_CALC_FOUND_ROWS usage.
		$count_query = new LLMS_Student_Query( $this->get_student_count_query_from_args( $args ) );
		if ( ! $count_query->get_count_only_result() ) {
			return $this->task_complete( $course, $this->get_task_data(), true );
		}
		$course->set( 'enrolled_students', $count_query->get_count_only_result() );

		// Throttle processing.
		if ( $this->maybe_throttle( $count_query->get_count_only_result(), $course_id ) ) {
			return $this->dispatch_calc_throttled( $course_id );
		}

		// Add each page to the queue.
		$max_pages = absint( ceil( $count_query->get_count_only_result() / $args['per_page'] ) );
		// Pass in the max pages as an argument to the task so we don't need to run the count query each time.
		$args['max_pages'] = $max_pages;
		while ( $args['page'] <= $max_pages ) {
			$this->push_to_queue( $args );
			++$args['page'];
		}

		// Save queue and dispatch the process.
		$this->save()->dispatch();
	}

	/**
	 * Schedule data calculation for the future
	 *
	 * This method is called when data processing is triggered for a course that is currently being processed
	 * or for a course that qualifies for process throttling based on the number of students in the course.
	 *
	 * @since 4.12.0
	 *
	 * @param int $course_id WP_Post ID of the course.
	 * @return void
	 */
	protected function dispatch_calc_throttled( $course_id ) {

		$this->schedule_calculation( $course_id, time() + $this->throttle_frequency );
		$this->log( sprintf( 'Course data calculation throttled for course %d.', $course_id ) );
	}

	/**
	 * Modify the query arguments for calculating the total count.
	 *
	 * @param $args
	 *
	 * @return array Array of arguments passed to an LLMS_Student_Query for calculating the student count.
	 */
	protected function get_student_count_query_from_args( $args ) {
		$count_args = array_merge( $args, array( 'count_only' => true ) );

		return $count_args;
	}

	/**
	 * Retrieve arguments used to perform an LLMS_Student_Query for background data processing
	 *
	 * @since 4.12.0
	 *
	 * @param int $course_id WP_Post ID of the course.
	 * @return array Array of arguments passed to an LLMS_Student_Query.
	 */
	protected function get_student_query_args( $course_id ) {

		/**
		 * Filter the query arguments used when calculating course data
		 *
		 * @since 4.12.0
		 *
		 * @param array                      $args      Query arguments passed to LLMS_Student_Query.
		 * @param LLMS_Processor_Course_Data $processor Instance of the data processor class.
		 */
		return apply_filters(
			'llms_data_processor_course_data_student_query_args',
			array(
				'post_id'       => $course_id,
				'statuses'      => array( 'enrolled' ),
				'page'          => 1,
				'per_page'      => 100,
				'sort'          => array(
					'id' => 'ASC',
				),
				'no_found_rows' => true,
			),
			$this
		);
	}

	/**
	 * Retrieve a timestamp for the last time data calculation was completed for a given course
	 *
	 * @since 4.12.0
	 *
	 * @param int $course_id WP_Post ID of the course.
	 * @return int The timestamp of the last run. Returns `0` when no data recorded.
	 */
	protected function get_last_run( $course_id ) {
		return absint( get_post_meta( $course_id, '_llms_last_data_calc_run', true ) );
	}

	/**
	 * Retrieve structured task data array.
	 *
	 * Ensures the expected required array keys are found on the task array
	 * and optionally merges in an existing array of day with the (empty) defaults.
	 *
	 * @since 4.21.0
	 *
	 * @param array $data Existing array of day (from a previous task).
	 * @return array
	 */
	protected function get_task_data( $data = array() ) {
		return wp_parse_args(
			$data,
			array(
				'students' => 0,
				'progress' => 0,
				'quizzes'  => 0,
				'grade'    => 0,
			)
		);
	}

	/**
	 * Initializer
	 *
	 * @since 3.15.0
	 *
	 * @return void
	 */
	protected function init() {

		// For the cron.
		add_action( $this->schedule_hook, array( $this, 'dispatch_calc' ), 10, 1 );

		// For LifterLMS actions which trigger recalculation.
		$this->actions = array(
			'llms_course_calculate_data'    => array(
				'arguments' => 1,
				'callback'  => 'schedule_calculation',
				'priority'  => 10,
			),
			'llms_user_enrolled_in_course'  => array(
				'arguments' => 2,
				'callback'  => 'schedule_from_course',
				'priority'  => 10,
			),
			'llms_user_removed_from_course' => array(
				'arguments' => 2,
				'callback'  => 'schedule_from_course',
				'priority'  => 10,
			),
			'lifterlms_lesson_completed'    => array(
				'arguments' => 2,
				'callback'  => 'schedule_from_lesson',
				'priority'  => 10,
			),
			'lifterlms_quiz_completed'      => array(
				'arguments' => 3,
				'callback'  => 'schedule_from_quiz',
				'priority'  => 10,
			),
		);

		/**
		 * Throttles course data processing based on the number of a students in a course.
		 *
		 * If the number of students in a course is greater than or equal to this number, the background
		 * process will be throttled to run only once every N hours where N is equal to the number of hours
		 * defined by the `llms_data_processor_course_data_throttle_frequency` filter.
		 *
		 * @since 3.15.0
		 * @since 4.12.0 Reduced default value of `$number_students` from 2500 to 500.
		 *
		 * @see llms_data_processor_course_data_throttle_frequency
		 *
		 * @param int                        $number_students The number of students. Default is `500`.
		 * @param LLMS_Processor_Course_Data $processor       Instance of the data processor class.
		 */
		$this->throttle_max_students = apply_filters( 'llms_data_processor_course_data_throttle_count', 500, $this );

		/**
		 * Frequency to run the processor for a given course when processing is throttled
		 *
		 * @since 3.15.0
		 *
		 * @see llms_data_processor_course_data_throttle_count
		 *
		 * @param int                        $frequency Frequency of the calculation process in seconds. Default `HOUR_IN_SECONDS * 4`.
		 * @param LLMS_Processor_Course_Data $processor Instance of the data processor class.
		 */
		$this->throttle_frequency = apply_filters( 'llms_data_processor_course_data_throttle_frequency', HOUR_IN_SECONDS * 4, $this );
	}

	/**
	 * Determines if the supplied course is already being processed.
	 *
	 * If it's already being processed we'll throttle the processing so we'll wait until the course
	 * completes its current data processing and start again later.
	 *
	 * @since 4.12.0
	 *
	 * @param int $course_id WP_Post ID of the course.
	 * @return boolean
	 */
	protected function is_already_processing_course( $course_id ) {
		return llms_parse_bool( get_post_meta( $course_id, '_llms_temp_calc_data_lock', true ) );
	}

	/**
	 * For large courses, only recalculate once every 4 hours
	 *
	 * @since 3.15.0
	 * @since 4.12.0 Adjusted access from private to protected.
	 *               Pull last run data on a per-course basis.
	 *               Added parameter `$course_id`.
	 *
	 * @param int $num_students Number of students in the current course.
	 * @param int $course_id    WP_Post ID of the course.
	 * @return boolean When `true` the dispatch is throttled and when `false` it will run.
	 */
	protected function maybe_throttle( $num_students, $course_id ) {

		$throttled = false;

		if ( $num_students >= $this->throttle_max_students ) {

			$throttled = ( time() - $this->get_last_run( $course_id ) <= $this->throttle_frequency );

		}

		/**
		 * Filters whether or not data processing is throttled for a request
		 *
		 * @since 4.12.0
		 *
		 * @param boolean $throttled    If `true`, the processing for the current request is throttled, otherwise data processing will begin.
		 * @param int     $num_students Number of students in the current course.
		 * @param int     $course_id    WP_Post ID of the course.
		 * $param int     $max_students Maximum number of students in the course before processing is throttled.
		 */
		return apply_filters( 'llms_data_processor_course_data_throttled', $throttled, $num_students, $course_id, $this->throttle_max_students );
	}

	/**
	 * Schedule recalculation from actions triggered against a course
	 *
	 * @since 3.15.0
	 *
	 * @param int $user_id   WP user id of the student.
	 * @param int $course_id WP Post ID of the course.
	 * @return void
	 */
	public function schedule_from_course( $user_id, $course_id ) {
		$this->schedule_calculation( $course_id );
	}

	/**
	 * Schedule recalculation from actions triggered against a lesson
	 *
	 * @since 3.15.0
	 *
	 * @param int $user_id   WP user id of the student.
	 * @param int $lesson_id WP Post ID of the lesson.
	 * @return void
	 */
	public function schedule_from_lesson( $user_id, $lesson_id ) {
		$lesson = llms_get_post( $lesson_id );
		$this->schedule_calculation( $lesson->get( 'parent_course' ) );
	}

	/**
	 * Schedule recalculation from actions triggered against a quiz
	 *
	 * @since 3.15.0
	 *
	 * @param int               $user_id WP user id of the student.
	 * @param int               $quiz_id WP Post ID of the quiz.
	 * @param LLMS_Quiz_Attempt $attempt Quiz attempt object.
	 * @return void
	 */
	public function schedule_from_quiz( $user_id, $quiz_id, $attempt ) {
		$this->schedule_from_lesson( $user_id, $attempt->get( 'lesson_id' ) );
	}

	/**
	 * Schedule a calculation to execute
	 *
	 * This will schedule an event that will setup the queue of items for the background process.
	 *
	 * @since 3.15.0
	 * @since 4.21.0 Force `$course_id` to an absolute integer to avoid duplicate scheduling resulting from loose variable typing.
	 *
	 * @param int $course_id WP Post ID of the course.
	 * @param int $time      Optionally pass a timestamp for when the event should be run.
	 * @return void
	 */
	public function schedule_calculation( $course_id, $time = null ) {

		$course_id = absint( $course_id );

		$this->log( sprintf( 'Course data calculation triggered for course %d.', $course_id ) );

		$args = array( $course_id );

		if ( ! wp_next_scheduled( $this->schedule_hook, $args ) ) {

			$time = ! $time ? time() : $time;

			wp_schedule_single_event( $time, $this->schedule_hook, $args );
			$this->log( sprintf( 'Course data calculation scheduled for course %d.', $course_id ) );

		}
	}


	/**
	 * Execute calculation for each item in the queue until all students in the course have been polled
	 *
	 * Stores the data in the postmeta table to be accessible via LLMS_Course.
	 *
	 * @since 3.15.0
	 * @since 4.12.0 Moved task completion logic to `task_complete()`.
	 * @since 4.16.0 Fix log string to properly record the post_id.
	 * @since 4.21.0 Use `get_task_data()` to merge/retrieve aggregate task data.
	 *               Return early for non-courses.
	 *
	 * @param array $args Query arguments passed to LLMS_Student_Query.
	 * @return boolean Always returns `false` to remove the item from the queue when processing is complete.
	 */
	public function task( $args ) {

		$this->log( sprintf( 'Course data calculation task called for course %1$d with args: %2$s', $args['post_id'], wp_json_encode( $args ) ) );

		$course = llms_get_post( $args['post_id'] );

		// Only process existing courses.
		if ( ! $course instanceof LLMS_Course ) {
			$this->log( sprintf( 'Course data calculation task skipped for course %1$d.', $args['post_id'] ) );
			return false;
		}

		// Lock the course against duplicate processing.
		$course->set( 'temp_calc_data_lock', 'yes' );

		// Get saved data or empty array when on first page.
		$data = ( 1 !== $args['page'] ) ? $course->get( 'temp_calc_data' ) : array();

		// Merge with the defaults.
		$data = $this->get_task_data( $data );

		/**
		 * Save the max number of pages value passed in when the tasks were pushed to the queue.
		 *
		 * If a task was dispatched without it, set the max pages to 0 so we can try to calculate the count
		 * using a new count-only query.
		 */
		$max_pages = isset( $args['max_pages'] ) ? $args['max_pages'] : 0;
		unset( $args['max_pages'] );

		// Perform the query.
		$query = new LLMS_Student_Query( $args );

		foreach ( $query->get_students() as $student ) {

			// Progress, all students counted here.
			++$data['students'];
			$data['progress'] = $data['progress'] + $student->get_progress( $args['post_id'] );

			// Grades only counted when a student has taken a quiz.
			// If a student hasn't taken it, we don't count it as a 0 on the quiz.
			$grade = $student->get_grade( $args['post_id'] );

			// Only check actual quiz grades.
			if ( is_numeric( $grade ) ) {
				++$data['quizzes'];
				$data['grade'] = $data['grade'] + $grade;
			}
		}

		if ( $max_pages === 0 ) {
			$count_query = new LLMS_Student_Query( $this->get_student_count_query_from_args( $args ) );
			if ( ! $count_query->get_count_only_result() ) {
				// End processing when we can't get the count.
				$is_last_page = true;
				return $this->task_complete( $course, $data, $is_last_page );
			}

			$max_pages = absint( ceil( $count_query->get_count_only_result() / $args['per_page'] ) );
		}
		$is_last_page = ( absint( $max_pages ) === absint( $args['page'] ) );

		return $this->task_complete( $course, $data, $is_last_page );
	}

	/**
	 * Complete a task
	 *
	 * Stores the current (incomplete) array of course data on the postmeta table for use
	 * by the next task in the queue.
	 *
	 * Upon completion, uses the data array to calculate the final aggregate values and store
	 * them on the postmeta table for the course for quick retrieval later.
	 *
	 * @since 4.12.0
	 * @since 4.16.0 Fix log string to properly log the course id.
	 *
	 * @param LLMS_Course $course    Course object.
	 * @param array       $data      Aggregate calculation data array.
	 * @param boolean     $last_page Whether or not this is the last page set of students for the process.
	 * @return boolean Always returns false.
	 */
	protected function task_complete( $course, $data, $last_page ) {

		$this->log( sprintf( 'Course data calculation task completed for course %1$d with data: %2$s', $course->get( 'id' ), wp_json_encode( $data ) ) );

		// Save our work on the last run.
		if ( $last_page ) {

			// Calculate.
			$grade    = $data['quizzes'] ? round( $data['grade'] / $data['quizzes'], 2 ) : 0;
			$progress = $data['students'] ? round( $data['progress'] / $data['students'], 2 ) : 0;

			// Save the data to the course.
			$course->set( 'average_grade', $grade );
			$course->set( 'average_progress', $progress );
			$course->set( 'enrolled_students', $data['students'] );
			$course->set( 'last_data_calc_run', time() );

			// Delete the temporary data so its fresh for next time.
			delete_post_meta( $course->get( 'id' ), '_llms_temp_calc_data' );

			// Unlock the course.
			delete_post_meta( $course->get( 'id' ), '_llms_temp_calc_data_lock' );

			$this->log( sprintf( 'Course data calculation completed for course %d.', $course->get( 'id' ) ) );

		} else {

			// Save temporary data so it can be used by the next run in the process.
			$course->set( 'temp_calc_data', $data );

		}

		return false;
	}
}

return new LLMS_Processor_Course_Data();
