<?php
/**
 * Quiz Attempt Model
 *
 * @package LifterLMS/Models/Classes
 *
 * @since 3.9.0
 * @version 4.3.0
 */

defined( 'ABSPATH' ) || exit;

/**
 * LLMS_Quiz_Attempt model class
 *
 * @since 3.9.0
 * @since 3.9.2 Added `calculate_point_weight()`, `get_question_order()`, `is_passing()` methods.
 * @since 3.16.0 Unknown.
 * @since 3.16.7 Unknown.
 * @since 3.17.1 Unknown.
 * @since 3.19.2 Unknown.
 * @since 3.24.0 Unknown.
 * @since 3.26.3 Unknown.
 * @since 3.29.0 Unknown.
 * @since 4.0.0 Remove reliance on deprecated method `LLMS_Quiz::get_passing_percent()` & remove deprecated class method `get_status()`.
 *              Fix issue encountered when answering a question incorrectly after initially answering it correctly.
 * @since 4.2.0 Use strict type comparisons where possible.
 *              In the `l10n()` method, made sure the status key exists to avoid trying to access to array's undefined index.
 *              Added the public method `get_siblings()`.
 * @since 4.3.0 Added `$type` property declaration.
 */
class LLMS_Quiz_Attempt extends LLMS_Abstract_Database_Store {

	/**
	 * Array of table column name => format
	 *
	 * @var array
	 */
	protected $columns = array(
		'student_id'  => '%d',
		'quiz_id'     => '%d',
		'lesson_id'   => '%d',
		'start_date'  => '%s',
		'update_date' => '%s',
		'end_date'    => '%s',
		'status'      => '%s',
		'attempt'     => '%d',
		'grade'       => '%f',
		'questions'   => '%s',
	);

	protected $date_created = 'start_date';
	protected $date_updated = 'update_date';

	/**
	 * Database Table Name
	 *
	 * @var string
	 */
	protected $table = 'quiz_attempts';

	/**
	 * The record type
	 *
	 * @var string
	 */
	protected $type = 'quiz_attempt';

	/**
	 * Constructor
	 *
	 * @since 3.9.0
	 * @since 3.16.0 Unknown.
	 *
	 * @param mixed $item Optional. Array/obj of attempt data or int. Default `null`.
	 * @return void
	 */
	public function __construct( $item = null ) {

		if ( is_numeric( $item ) ) {

			$this->id = $item;

		} elseif ( is_object( $item ) && isset( $item->id ) ) {

			$this->id = $item->id;

		} elseif ( is_array( $item ) && isset( $item['id'] ) ) {

			$this->id = $item['id'];

		}

		if ( ! $this->id ) {

			if ( is_array( $item ) || is_object( $item ) ) {
				$this->setup( $item );
			}

			parent::__construct();

		}

	}

	/**
	 * Answer a question
	 *
	 * Records the selected option and whether or not the selected option was the correct option.
	 *
	 * Automatically updates & saves the attempt to the database
	 *
	 * @since 3.9.0
	 * @since 3.16.0 Updated to accommodate quiz builder improvements.
	 * @since 4.0.0 Explicitly set earned points to `0` when answering incorrectly.
	 *              Exit the loop as soon as we find our question.
	 *              Use strict comparison for IDs.
	 *
	 * @param int      $question_id WP_Post ID of the LLMS_Question.
	 * @param string[] $answer      Array of selected choice IDs (for core question types) or an array containing the user-submitted answer(s).
	 * @return LLMS_Quiz_Attempt Instance of the current attempt.
	 */
	public function answer_question( $question_id, $answer ) {

		$questions = $this->get_questions();

		foreach ( $questions as $key => $data ) {

			if ( absint( $question_id ) !== absint( $data['id'] ) ) {
				continue;
			}

			$question                     = llms_get_post( $question_id );
			$graded                       = $question->grade( $answer );
			$questions[ $key ]['answer']  = $answer;
			$questions[ $key ]['correct'] = $graded;
			$questions[ $key ]['earned']  = llms_parse_bool( $graded ) ? $questions[ $key ]['points'] : 0;

			break;
		}

		$this->set_questions( $questions )->save();

		return $this;

	}

	/**
	 * Calculate and the grade for a completed quiz
	 *
	 * @since 3.9.0
	 * @since 3.24.0 Unknown.
	 * @since 4.0.0 Remove reliance on deprecated method `LLMS_Quiz::get_passing_percent()`.
	 *
	 * @return LLMS_Quiz_Attempt Instance of the current quiz attempt.
	 */
	public function calculate_grade() {

		$status = 'pending';

		if ( $this->is_auto_gradeable() ) {

			$grade = LLMS()->grades()->round( $this->get_count( 'earned' ) * $this->calculate_point_weight() );

			$quiz      = $this->get_quiz();
			$min_grade = $quiz ? $quiz->get( 'passing_percent' ) : 100;

			$this->set( 'grade', $grade );
			$status = ( $min_grade <= $grade ) ? 'pass' : 'fail';

		}

		$this->set_status( $status );

		return $this;

	}

	/**
	 * Calculate the weight of each point
	 *
	 * @since 3.9.2
	 * @since 3.16.0 Unknown.
	 *
	 * @return float
	 */
	private function calculate_point_weight() {
		$available = $this->get_count( 'available_points' );
		return ( $available > 0 ) ? ( 100 / $available ) : 0;
	}

	/**
	 * Run actions designating quiz completion
	 *
	 * @since 3.16.0
	 * @since 3.17.1 Unknown.
	 *
	 * @return void
	 */
	public function do_completion_actions() {

		// Do quiz completion actions.
		do_action( 'lifterlms_quiz_completed', $this->get_student()->get_id(), $this->get( 'quiz_id' ), $this );

		$passed = false;

		switch ( $this->get( 'status' ) ) {

			case 'pass':
				$passed = true;
				do_action( 'lifterlms_quiz_passed', $this->get_student()->get_id(), $this->get( 'quiz_id' ), $this );
				break;

			case 'fail':
				do_action( 'lifterlms_quiz_failed', $this->get_student()->get_id(), $this->get( 'quiz_id' ), $this );
				break;

			case 'pending':
				do_action( 'lifterlms_quiz_pending', $this->get_student()->get_id(), $this->get( 'quiz_id' ), $this );
				break;

		}

	}

	/**
	 * End a quiz attempt
	 *
	 * Sets end date, unsets the quiz as the current quiz, and records a grade.
	 *
	 * @since 3.9.0
	 * @since 3.16.0 Unknown.
	 *
	 * @param boolean $silent Optional. If `true`, will not trigger actions or mark related lesson as complete. Default `false`.
	 * @return LLMS_Quiz_Attempt This quiz attempt instance (for chaining).
	 */
	public function end( $silent = false ) {

		$this->set( 'end_date', current_time( 'mysql' ) );
		$this->calculate_grade()->save();

		if ( ! $silent ) {

			$this->do_completion_actions();

		}

		// Clear "cached" grade so it's recalculated next time it's requested.
		$this->get_student()->set( 'overall_grade', '' );

		return $this;

	}

	/**
	 * Get sibling attempts
	 *
	 * @since 4.2.0
	 *
	 * @param array  $args Optional. List of args to be passed as params of the quiz attempts query. Default empty array.
	 *                     See `LLMS_Query_Quiz_Attempt` and `LLMS_Database_Query` for the list of args.
	 *                     By default the `per_page` param is set to 1000.
	 * @param string $return Optional. Type of return [ids|attempts]. Default 'attempts'.
	 * @return int[]|LLMS_Quiz_Attempt[] Type depends on value of `$return`.
	 */
	public function get_siblings( $args = array(), $return = 'attempts' ) {

		$defaults = array(
			'per_page' => 1000,
		);

		$args  = wp_parse_args( $args, $defaults );
		$query = new LLMS_Query_Quiz_Attempt(
			array_merge(
				$args,
				array(
					'student_id' => $this->get( 'student_id' ),
					'quiz_id'    => $this->get( 'quiz_id' ),
				)
			)
		);

		return 'ids' === $return ? wp_list_pluck( $query->get_results(), 'id' ) : $query->get_attempts();
	}

	/**
	 * Retrieve a count for various pieces of information related to the attempt
	 *
	 * @since 3.9.0
	 * @since 3.19.2 Unknown.
	 * @since 4.2.0 Ensure only one return point.
	 *
	 * @param string $key The key of the data to count.
	 * @return int
	 */
	public function get_count( $key ) {

		$count     = 0;
		$questions = $this->get_questions();

		switch ( $key ) {

			case 'available_points':
			case 'correct_answers':
			case 'earned':
			case 'gradeable_questions': // Like "questions" but excludes content questions.
			case 'points': // Legacy version of earned.
				foreach ( $questions as $data ) {
					// Get the total number of correct answers.
					if ( 'correct_answers' === $key ) {
						if ( 'yes' === $data['correct'] ) {
							$count++;
						}
					} elseif ( 'earned' === $key || 'points' === $key ) {
						$count += $data['earned'];
						// Get the total number of possible points.
					} elseif ( 'available_points' === $key ) {
						$count += $data['points'];
					} elseif ( 'gradeable_questions' === $key ) {
						if ( $data['points'] ) {
							$count++;
						}
					}
				}
				break;

			case 'questions':
				$count = count( $questions );
				break;

		}

		return $count;

	}

	/**
	 * Retrieve a formatted date
	 *
	 * @since 3.9.0
	 * @since 3.16.0 Unknown.
	 *
	 * @param string $key    'start' or 'end'.
	 * @param string $format Optional. Output date format (PHP), uses WordPress format options if none provided.
	 *                       If not provided defaults to WP date format options.
	 * @return string
	 */
	public function get_date( $key, $format = null ) {

		$date   = strtotime( $this->get( $key . '_date' ) );
		$format = ! $format ? get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) : $format;
		return date_i18n( $format, $date );

	}

	/**
	 * Retrieve the first question for the attempt
	 *
	 * @since 3.9.0
	 * @since 3.16.0 Unknown.
	 *
	 * @return int|false
	 */
	public function get_first_question() {

		$questions = $this->get_questions();
		if ( $questions ) {
			$first = array_shift( $questions );
			return $first['id'];
		}

		return false;

	}

	/**
	 * Get the numeric order of a question in a given quiz
	 *
	 * @since 3.9.2
	 * @since 3.16.0 Unknown.
	 * @since 4.2.0 Use strict type comparison.
	 *
	 * @param int $question_id WP Post ID of the LLMS_Question.
	 * @return int
	 */
	public function get_question_order( $question_id ) {

		foreach ( $this->get_questions() as $order => $data ) {

			if ( absint( $data['id'] ) === $question_id ) {
				return $order + 1;
			}
		}

		return 0;

	}

	/**
	 * Get an encoded attempt key that can be passed in URLs and the like
	 *
	 * @since 3.9.0
	 * @since 3.16.7 Unknown.
	 *
	 * @return string
	 */
	public function get_key() {
		return LLMS_Hasher::hash( $this->get( 'id' ) );
	}

	/**
	 * Retrieve an array of blank questions for insertion into a new attempt during initialization
	 *
	 * @since 3.9.0
	 * @since 3.16.0 Unknown.
	 *
	 * @return array
	 */
	private function get_new_questions() {

		$quiz = llms_get_post( $this->get( 'quiz_id' ) );

		$questions = array();

		if ( $quiz ) {

			$randomize = llms_parse_bool( $quiz->get( 'random_questions' ) );

			// Array of indexes that will be locked during shuffling.
			$locks = array();

			foreach ( $quiz->get_questions() as $index => $question ) {

				// If randomization is enabled, store the questions index so we can lock it during randomization.
				if ( $randomize && $question->supports( 'random_lock' ) ) {
					$locks[] = $index;
				}

				$questions[] = array(
					'id'      => $question->get( 'id' ),
					'earned'  => 0,
					'points'  => $question->supports( 'points' ) ? $question->get( 'points' ) : 0,
					'answer'  => null,
					'correct' => null,
				);

			}

			if ( $randomize ) {

				// Lifted from https://stackoverflow.com/a/28491007/400568.
				// I generally comprehend this code but also in a truer way i have no idea...
				$inc = array();
				$i   = 0;
				$j   = 0;
				$l   = count( $questions );
				$le  = count( $locks );
				while ( $i < $l ) {
					if ( $j >= $le || $i < $locks[ $j ] ) {
						$inc[] = $i;
					} else {
						$j++;
					}
					$i++;
				}

				// Fisher-yates-knuth shuffle variation O(n).
				$num = count( $inc );
				while ( $num-- ) {
					$perm                       = rand( 0, $num );
					$swap                       = $questions[ $inc[ $num ] ];
					$questions[ $inc[ $num ] ]  = $questions[ $inc[ $perm ] ];
					$questions[ $inc[ $perm ] ] = $swap;
				}
			}
		}

		return $questions;

	}

	/**
	 * Retrieve the next unanswered question in the attempt
	 *
	 * @since 3.9.0
	 * @since 3.16.0 Unknown.
	 * @since 4.2.0 Use strict type comparison.
	 *
	 * @param int $last_question Optional. WP Post ID of the current LLMS_Question the "next" refers to. Default `null`.
	 * @return int|false
	 */
	public function get_next_question( $last_question = null ) {

		$next = false;

		foreach ( $this->get_questions() as $question ) {

			if ( $next || is_null( $question['answer'] ) ) {
				return $question['id'];

				// When rewinding and moving back through we don't want to skip questions.
			} elseif ( $last_question && absint( $last_question ) === absint( $question['id'] ) ) {
				$next = true;
			}
		}

		return false;

	}

	/**
	 * Retrieve a permalink for the attempt
	 *
	 * @since 3.9.0
	 *
	 * @return string
	 */
	public function get_permalink() {
		if ( ! $this->get_quiz() ) {
			return '';
		}
		return add_query_arg( 'attempt_key', $this->get_key(), get_permalink( $this->get_quiz()->get( 'id' ) ) );
	}

	/**
	 * Get array of serialized questions
	 *
	 * @since 3.16.0
	 *
	 * @param boolean $cache Optional. If `true`, save data to to the object for future gets. Default `true`.
	 * @return array
	 */
	public function get_questions( $cache = true ) {

		$questions = $this->get( 'questions', $cache );
		if ( $questions ) {
			return unserialize( $questions );
		}
		return array();

	}

	/**
	 * Retrieve an array of attempt question objects
	 *
	 * @since 3.16.0
	 * @since 5.3.0 Add a parameter to filter out removed questions.
	 *
	 * @param boolean $cache          Optional. If `true`, save data to to the object for future gets. Default `true`.
	 *                                Cached questions won't take into account the `$filte_removed` parameter.
	 * @param boolean $filter_removed Optional. If `true`, removed questions will be filtered out. Default `false`.
	 * @return array
	 */
	public function get_question_objects( $cache = true, $filter_removed = false ) {

		$questions = array();
		foreach ( $this->get_questions( $cache ) as $qdata ) {
			$question = new LLMS_Quiz_Attempt_Question( $qdata );
			if ( ! $filter_removed || $question->get_question() instanceof LLMS_Question ) {
				$questions[] = $question;
			}
		}
		return $questions;

	}

	/**
	 * Get an instance of the LLMS_Quiz for the attempt
	 *
	 * @since 3.9.0
	 *
	 * @return LLMS_Quiz
	 */
	public function get_quiz() {
		return llms_get_post( $this->get( 'quiz_id' ) );
	}

	/**
	 * Get an LLMS_Student for the quiz
	 *
	 * @since 3.9.0
	 *
	 * @return LLMS_Student
	 */
	public function get_student() {
		return llms_get_student( $this->get( 'student_id' ) );
	}

	/**
	 * Get the time spent on the quiz from start to end
	 *
	 * @since 3.9.0
	 *
	 * @param integer $precision Precision passed to `llms_get_date_diff()`.
	 * @return string
	 */
	public function get_time( $precision = 2 ) {
		return llms_get_date_diff( $this->get_date( 'start', 'U' ), $this->get_date( 'end', 'U' ), $precision );
	}

	/**
	 * Retrieve a title-like string
	 *
	 * @since 3.16.0
	 * @since 3.26.3 Unknown.
	 *
	 * @return string
	 */
	public function get_title() {
		$student = $this->get_student();
		$name    = $student ? $this->get_student()->get_name() : apply_filters( 'llms_quiz_attempt_deleted_student_name', __( '[Deleted]', 'lifterlms' ) );
		return sprintf( __( 'Quiz Attempt #%1$d by %2$s', 'lifterlms' ), $this->get( 'attempt' ), $name );
	}

	/**
	 * Initialize a new quiz attempt by quiz and lesson for a user
	 *
	 * If no user found throws an Exception.
	 *
	 * @since 3.9.0
	 * @version 3.16.0
	 *
	 * @throws Exception When the user is not logged in.
	 *
	 * @param int   $quiz_id   WP Post ID of the quiz.
	 * @param int   $lesson_id WP Post ID of the lesson.
	 * @param mixed $student   Optional. Accepts anything that can be passed to llms_get_student.
	 *                         If no user is passed the current user will be used. Default `null`.
	 *
	 * @return obj
	 */
	public static function init( $quiz_id, $lesson_id, $student = null ) {

		$student = llms_get_student( $student );
		if ( ! $student ) {
			throw new Exception( __( 'You must be logged in to take a quiz!', 'lifterlms' ) );
		}

		// Initialize a new attempt.
		$attempt = new self();
		$attempt->set( 'quiz_id', $quiz_id );
		$attempt->set( 'lesson_id', $lesson_id );
		$attempt->set( 'student_id', $student->get_id() );
		$attempt->set_status( 'incomplete' );
		$attempt->set_questions( $attempt->get_new_questions() );

		$number = 1;

		$last_attempt = $student->quizzes()->get_last_attempt( $quiz_id );
		if ( $last_attempt ) {
			$number = absint( $last_attempt->get( 'attempt' ) ) + 1;
		}
		$attempt->set( 'attempt', $number );

		return $attempt;

	}

	/**
	 * Determine if the attempt can be autograded
	 *
	 * @since 3.16.0
	 *
	 * @return bool
	 */
	private function is_auto_gradeable() {

		foreach ( $this->get_question_objects() as $question ) {

			if ( 'waiting' === $question->get_status() ) {
				return false;
			}
		}

		return true;

	}

	/**
	 * Determine if the attempt was passing
	 *
	 * @since 3.9.2
	 * @since 3.16.0 Unknown.
	 *
	 * @return boolean
	 */
	public function is_passing() {
		return ( 'pass' === $this->get( 'status' ) );
	}

	/**
	 * Translate attempt related strings
	 *
	 * @since 3.9.0
	 * @since 3.16.0 Unknown.
	 * @since 4.2.0 Made sure the status key exists to avoid trying to access to array's undefined index.
	 *
	 * @param string $key Key to translate.
	 * @return string
	 */
	public function l10n( $key ) {

		$tkey = '';

		switch ( $key ) {

			case 'passed': // Deprecated.
			case 'status':
				$statuses = llms_get_quiz_attempt_statuses();
				$status   = $this->get( 'status' );
				$tkey     = ( $status && isset( $statuses[ $status ] ) ) ? $statuses[ $status ] : $tkey;
				break;

		}

		return $tkey;

	}

	/**
	 * Setter for serialized questions array
	 *
	 * @since 3.16.0
	 *
	 * @param array   $questions Question data.
	 * @param boolean $save      Optional. If `true`, immediately persists to database. Default `false`.
	 * @return LLMS_Quiz_Attempt This quiz attempt instance (for chaining).
	 */
	public function set_questions( $questions = array(), $save = false ) {
		return $this->set( 'questions', serialize( $questions ), $save );
	}

	/**
	 * Set the status of the attempt
	 *
	 * @since 3.16.0
	 * @since 4.0.0 Use strict comparisons.
	 *
	 * @param string  $status Status value.
	 * @param boolean $save   If `true`, immediately persists to database.
	 * @return false|LLMS_Quiz_Attempt
	 */
	public function set_status( $status, $save = false ) {

		$statuses = array_keys( llms_get_quiz_attempt_statuses() );
		if ( ! in_array( $status, $statuses, true ) ) {
			return false;
		}
		return $this->set( 'status', $status );

	}

	/**
	 * Record the attempt as started
	 *
	 * @since 3.9.0
	 *
	 * @return LLMS_Quiz_Attempt Instance of the current quiz attempt object.
	 */
	public function start() {

		$this->set( 'start_date', current_time( 'mysql' ) );
		$this->save();
		return $this;

	}

	/**
	 * Retrieve the private data array
	 *
	 * @since 3.9.0
	 *
	 * @return array
	 */
	public function to_array() {
		return $this->data;
	}

	/**
	 * Delete the object from the database
	 *
	 * Overrides the parent method to perform other actions before deletion.
	 *
	 * @since 4.2.0
	 *
	 * @return bool `true` on success, `false` otherwise.
	 */
	public function delete() {

		if ( ! $this->id ) {
			return false;
		}

		$lesson = llms_get_post( $this->get( 'lesson_id' ) );

		// No lesson, or lesson incomplete, nothing special to do here.
		if ( ! $lesson || ! ( $lesson instanceof LLMS_Lesson ) || ! llms_is_complete( $this->get( 'student_id' ), $this->get( 'lesson_id' ), 'lesson' ) ) {
			return parent::delete();
		}

		/**
		 * Prepare the query args to retrieve at least another sibling attempt,
		 * excluding the current one.
		 */
		$sibling_query_args = array(
			'exclude'  => $this->get_id( 'id' ),
			'per_page' => 1,
		);

		/**
		 * If this lesson requires a passing grade, then retrieve only the possible passed sibling
		 * that might have been triggered the lesson completion.
		 */
		if ( llms_parse_bool( $lesson->get( 'require_passing_grade' ) ) ) {
			$sibling_query_args['status'] = array(
				'pass',
			);
		}

		$sibling_attempts = $this->get_siblings( $sibling_query_args, 'ids' );

		// If this is the only one relevant left attempt.
		if ( empty( $sibling_attempts ) ) {
			llms_mark_incomplete(
				$this->get( 'student_id' ),
				$this->get( 'lesson_id' ),
				'lesson',
				'quiz_' . $this->get( 'quiz_id' )
			);
		}

		return parent::delete();
	}

}
