<?php
/**
 * Question model
 *
 * Represents a quiz question with versioning, categories, and metadata.
 *
 * @package PressPrimer_Quiz
 * @subpackage Models
 * @since 1.0.0
 */

// Prevent direct access
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Question model class
 *
 * Handles CRUD operations for questions, including soft delete,
 * revision management, and category/tag relationships.
 *
 * @since 1.0.0
 */
class PressPrimer_Quiz_Question extends PressPrimer_Quiz_Model {

	/**
	 * Question UUID
	 *
	 * @since 1.0.0
	 * @var string
	 */
	public $uuid = '';

	/**
	 * Author user ID
	 *
	 * @since 1.0.0
	 * @var int
	 */
	public $author_id = 0;

	/**
	 * Question type
	 *
	 * @since 1.0.0
	 * @var string mc|ma|tf
	 */
	public $type = 'mc';

	/**
	 * Expected completion time in seconds
	 *
	 * @since 1.0.0
	 * @var int|null
	 */
	public $expected_seconds = null;

	/**
	 * Author-assigned difficulty
	 *
	 * @since 1.0.0
	 * @var string|null easy|medium|hard
	 */
	public $difficulty_author = null;

	/**
	 * Maximum points for this question
	 *
	 * @since 1.0.0
	 * @var float
	 */
	public $max_points = 1.00;

	/**
	 * Question status
	 *
	 * @since 1.0.0
	 * @var string draft|published|archived
	 */
	public $status = 'published';

	/**
	 * Current revision ID
	 *
	 * @since 1.0.0
	 * @var int|null
	 */
	public $current_revision_id = null;

	/**
	 * Created timestamp
	 *
	 * @since 1.0.0
	 * @var string
	 */
	public $created_at = '';

	/**
	 * Last updated timestamp
	 *
	 * @since 1.0.0
	 * @var string
	 */
	public $updated_at = '';

	/**
	 * Soft delete timestamp
	 *
	 * @since 1.0.0
	 * @var string|null
	 */
	public $deleted_at = null;

	/**
	 * Cached current revision
	 *
	 * @since 1.0.0
	 * @var PressPrimer_Quiz_Question_Revision|null
	 */
	private $_current_revision = null;

	/**
	 * Cached categories
	 *
	 * @since 1.0.0
	 * @var array|null
	 */
	private $_categories = null;

	/**
	 * Cached tags
	 *
	 * @since 1.0.0
	 * @var array|null
	 */
	private $_tags = null;

	/**
	 * Get table name
	 *
	 * @since 1.0.0
	 *
	 * @return string Table name without prefix.
	 */
	protected static function get_table_name() {
		return 'ppq_questions';
	}

	/**
	 * Get fillable fields
	 *
	 * @since 1.0.0
	 *
	 * @return array Field names that can be mass-assigned.
	 */
	protected static function get_fillable_fields() {
		return [
			'uuid',
			'author_id',
			'type',
			'expected_seconds',
			'difficulty_author',
			'max_points',
			'status',
			'current_revision_id',
			'deleted_at',
		];
	}

	/**
	 * Get question by UUID
	 *
	 * @since 1.0.0
	 *
	 * @param string $uuid Question UUID.
	 * @return PressPrimer_Quiz_Question|null Question instance or null if not found.
	 */
	public static function get_by_uuid( $uuid ) {
		global $wpdb;

		$table = static::get_full_table_name();

		$row = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT * FROM {$table} WHERE uuid = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				$uuid
			)
		);

		return $row ? static::from_row( $row ) : null;
	}

	/**
	 * Create new question
	 *
	 * Validates input and creates a new question record.
	 *
	 * @since 1.0.0
	 *
	 * @param array $data Question data.
	 * @return int|WP_Error Question ID on success, WP_Error on failure.
	 */
	public static function create( array $data ) {
		// Validate required fields
		$validation = self::validate_data( $data );
		if ( is_wp_error( $validation ) ) {
			return $validation;
		}

		// Generate UUID if not provided
		if ( empty( $data['uuid'] ) ) {
			$data['uuid'] = PressPrimer_Quiz_Helpers::generate_uuid();
		}

		// Set author to current user if not provided
		if ( empty( $data['author_id'] ) ) {
			$data['author_id'] = get_current_user_id();
		}

		// Set default status if not provided
		if ( empty( $data['status'] ) ) {
			$data['status'] = 'published';
		}

		// Call parent create
		$question_id = parent::create( $data );

		// Fire action hook for addons (e.g., audit logging).
		if ( ! is_wp_error( $question_id ) ) {
			/**
			 * Fires after a question is created.
			 *
			 * @since 2.0.0
			 *
			 * @param int   $question_id The question ID.
			 * @param array $data        The question data.
			 */
			do_action( 'pressprimer_quiz_question_created', $question_id, $data );
		}

		return $question_id;
	}

	/**
	 * Save changes to database
	 *
	 * Updates the record in the database with hook for addons.
	 *
	 * @since 2.0.0
	 *
	 * @return bool|WP_Error True on success, WP_Error on failure.
	 */
	public function save() {
		$result = parent::save();

		// Fire action hook for addons (e.g., audit logging).
		// Only fire if not a soft-delete or restore operation (those have their own hooks).
		if ( true === $result && null === $this->deleted_at ) {
			/**
			 * Fires after a question is updated.
			 *
			 * @since 2.0.0
			 *
			 * @param PressPrimer_Quiz_Question $question The question instance.
			 */
			do_action( 'pressprimer_quiz_question_updated', $this );
		}

		return $result;
	}

	/**
	 * Validate question data
	 *
	 * @since 1.0.0
	 *
	 * @param array $data Question data to validate.
	 * @return true|WP_Error True on success, WP_Error on validation failure.
	 */
	protected static function validate_data( array $data ) {
		// Validate type
		if ( ! empty( $data['type'] ) && ! in_array( $data['type'], [ 'mc', 'ma', 'tf' ], true ) ) {
			return new WP_Error(
				'ppq_invalid_type',
				__( 'Invalid question type. Must be mc, ma, or tf.', 'pressprimer-quiz' )
			);
		}

		// Validate difficulty
		if ( ! empty( $data['difficulty_author'] ) && ! in_array( $data['difficulty_author'], [ 'beginner', 'intermediate', 'advanced', 'expert' ], true ) ) {
			return new WP_Error(
				'ppq_invalid_difficulty',
				__( 'Invalid difficulty. Must be beginner, intermediate, advanced, or expert.', 'pressprimer-quiz' )
			);
		}

		// Validate status
		if ( ! empty( $data['status'] ) && ! in_array( $data['status'], [ 'draft', 'published', 'archived' ], true ) ) {
			return new WP_Error(
				'ppq_invalid_status',
				__( 'Invalid status. Must be draft, published, or archived.', 'pressprimer-quiz' )
			);
		}

		// Validate expected_seconds
		if ( ! empty( $data['expected_seconds'] ) ) {
			$seconds = absint( $data['expected_seconds'] );
			if ( $seconds < 1 || $seconds > 3600 ) {
				return new WP_Error(
					'ppq_invalid_time',
					__( 'Expected time must be between 1 and 3600 seconds.', 'pressprimer-quiz' )
				);
			}
		}

		// Validate max_points
		if ( isset( $data['max_points'] ) ) {
			$points = floatval( $data['max_points'] );
			if ( $points < 0.01 || $points > 1000.00 ) {
				return new WP_Error(
					'ppq_invalid_points',
					__( 'Max points must be between 0.01 and 1000.00.', 'pressprimer-quiz' )
				);
			}
		}

		return true;
	}

	/**
	 * Validate question content (stem and answers)
	 *
	 * @since 1.0.0
	 *
	 * @param string $stem    Question stem/text.
	 * @param array  $answers Answer options.
	 * @param string $type    Question type (mc, ma, tf).
	 * @return true|WP_Error True on success, WP_Error on validation failure.
	 */
	public static function validate_content( $stem, $answers, $type = 'mc' ) {
		// Validate stem - must have at least 2 characters of actual content
		$clean_stem = wp_strip_all_tags( $stem );
		$clean_stem = trim( $clean_stem );
		if ( strlen( $clean_stem ) < 2 ) {
			return new WP_Error(
				'ppq_empty_stem',
				__( 'Question text is required and must contain at least 2 characters.', 'pressprimer-quiz' )
			);
		}

		// Validate answers for multiple choice and multiple answer types
		if ( in_array( $type, [ 'mc', 'ma' ], true ) ) {
			if ( empty( $answers ) || ! is_array( $answers ) ) {
				return new WP_Error(
					'ppq_no_answers',
					__( 'Answer options are required for this question type.', 'pressprimer-quiz' )
				);
			}

			// Check that we have at least 2 answers with actual content
			$valid_answers   = 0;
			$correct_answers = 0;

			foreach ( $answers as $answer ) {
				$answer_text = isset( $answer['text'] ) ? wp_strip_all_tags( $answer['text'] ) : '';
				$answer_text = trim( $answer_text );

				if ( strlen( $answer_text ) >= 2 ) {
					++$valid_answers;

					// Check if this answer is marked as correct
					$is_correct = isset( $answer['is_correct'] ) ? $answer['is_correct'] : ( isset( $answer['isCorrect'] ) ? $answer['isCorrect'] : false );
					if ( $is_correct ) {
						++$correct_answers;
					}
				}
			}

			if ( $valid_answers < 2 ) {
				return new WP_Error(
					'ppq_insufficient_answers',
					__( 'At least 2 answer options with text are required.', 'pressprimer-quiz' )
				);
			}

			if ( $correct_answers < 1 ) {
				return new WP_Error(
					'ppq_no_correct_answer',
					__( 'At least one answer must be marked as correct.', 'pressprimer-quiz' )
				);
			}
		}

		// Validate True/False questions
		if ( 'tf' === $type ) {
			if ( empty( $answers ) || ! is_array( $answers ) || count( $answers ) < 2 ) {
				return new WP_Error(
					'ppq_no_answers',
					__( 'Both True and False answer options are required.', 'pressprimer-quiz' )
				);
			}

			$valid_answers   = 0;
			$correct_answers = 0;

			foreach ( $answers as $answer ) {
				$answer_text = isset( $answer['text'] ) ? wp_strip_all_tags( $answer['text'] ) : '';
				$answer_text = trim( $answer_text );

				if ( strlen( $answer_text ) >= 2 ) {
					++$valid_answers;
				}

				$is_correct = isset( $answer['is_correct'] ) ? $answer['is_correct'] : ( isset( $answer['isCorrect'] ) ? $answer['isCorrect'] : false );
				if ( $is_correct ) {
					++$correct_answers;
				}
			}

			if ( $valid_answers < 2 ) {
				return new WP_Error(
					'ppq_tf_empty_answers',
					__( 'Both True and False answer options must have text (at least 2 characters each).', 'pressprimer-quiz' )
				);
			}

			if ( $correct_answers !== 1 ) {
				return new WP_Error(
					'ppq_tf_single_correct',
					__( 'True/False questions must have exactly one correct answer.', 'pressprimer-quiz' )
				);
			}
		}

		return true;
	}

	/**
	 * Delete question (soft delete)
	 *
	 * Sets deleted_at timestamp instead of removing the record.
	 *
	 * @since 1.0.0
	 *
	 * @return bool|WP_Error True on success, WP_Error on failure.
	 */
	public function delete() {
		if ( empty( $this->id ) ) {
			return new WP_Error(
				'ppq_no_id',
				__( 'Cannot delete question without ID.', 'pressprimer-quiz' )
			);
		}

		// Soft delete: set deleted_at timestamp
		$this->deleted_at = current_time( 'mysql' );

		$result = parent::save(); // Use parent::save() to avoid triggering _updated hook.

		// Update question counts for all banks containing this question.
		if ( true === $result ) {
			$this->update_bank_counts();

			/**
			 * Fires after a question is soft-deleted.
			 *
			 * @since 2.0.0
			 *
			 * @param PressPrimer_Quiz_Question $question The question instance.
			 */
			do_action( 'pressprimer_quiz_question_deleted', $this );
		}

		return $result;
	}

	/**
	 * Permanently delete question
	 *
	 * Removes the question record from the database. Use with caution — this
	 * cannot be undone. Before deletion, the question's owned image
	 * attachments are refcounted via the `_ppq_question_id` post meta and
	 * any attachment with no remaining owner is removed from disk and the
	 * Media Library. Cleanup failures are caught so they cannot block the
	 * question deletion itself.
	 *
	 * @since 1.0.0
	 * @since 2.3.0 Cleans up `_ppq_question_id`-tagged image attachments.
	 *
	 * @return bool|WP_Error True on success, WP_Error on failure.
	 */
	public function force_delete() {
		if ( ! empty( $this->id ) ) {
			try {
				$this->cleanup_owned_images();
			} catch ( Exception $e ) {
				// Cleanup must not block the question deletion. Log and
				// continue — orphaned attachments are recoverable from the
				// Media Library; a failed delete is not.
				// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
				error_log( sprintf( 'PressPrimer Quiz: image cleanup failed for question %d: %s', (int) $this->id, $e->getMessage() ) );
			}
		}

		return parent::delete();
	}

	/**
	 * Remove this question's row from each owned attachment's refcount meta;
	 * delete the attachment outright if no other question still references it.
	 *
	 * The `_ppq_question_id` post meta key allows multiple rows per
	 * attachment (one per owning question), so we use
	 * `delete_post_meta( $att, $key, $this->id )` to remove just our row,
	 * then count remaining rows to decide whether to delete the attachment.
	 *
	 * @since 2.3.0
	 *
	 * @return void
	 */
	private function cleanup_owned_images() {
		$attachment_ids = get_posts(
			array(
				'post_type'        => 'attachment',
				'meta_key'         => '_ppq_question_id', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
				'meta_value'       => (int) $this->id,    // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
				'fields'           => 'ids',
				'posts_per_page'   => -1,
				'suppress_filters' => false,
			)
		);

		if ( empty( $attachment_ids ) ) {
			return;
		}

		foreach ( $attachment_ids as $attachment_id ) {
			$attachment_id = (int) $attachment_id;

			// Remove this question's refcount row only.
			delete_post_meta( $attachment_id, '_ppq_question_id', (int) $this->id );

			// Count remaining rows. If anything else owns this attachment,
			// leave it alone — that question still needs it.
			$remaining = get_post_meta( $attachment_id, '_ppq_question_id', false );
			if ( empty( $remaining ) ) {
				wp_delete_attachment( $attachment_id, true );
			}
		}
	}

	/**
	 * Register this question as an owner of every attachment referenced in
	 * the given HTML blob.
	 *
	 * Used by the question-duplicate flow so the duplicated question shares
	 * refcount ownership of the original's images — deleting either one
	 * leaves the file intact for the other. The HTML is expected to be the
	 * full content the question references (stem + each answer text +
	 * feedback fields concatenated).
	 *
	 * Attachment IDs are read from the `data-ppq-attachment-id` attribute
	 * that the v2.3 image-upload modal writes on every inserted `<img>` tag.
	 * Older images that predate the data attribute are not tracked — they
	 * remain as orphan references in the original question and persist
	 * naturally because their refcount was never decremented.
	 *
	 * @since 2.3.0
	 *
	 * @param int    $question_id Question that should be added as an owner.
	 * @param string $html        Concatenated HTML to scan for attachment references.
	 * @return int Number of attachment refcount rows added.
	 */
	public static function register_image_ownership( $question_id, $html ) {
		$question_id = (int) $question_id;
		if ( $question_id <= 0 || ! is_string( $html ) || '' === $html ) {
			return 0;
		}

		$matched = preg_match_all( '/data-ppq-attachment-id=["\'](\d+)["\']/i', $html, $matches );
		if ( ! $matched ) {
			return 0;
		}

		$attachment_ids = array_unique( array_map( 'absint', $matches[1] ) );
		$added          = 0;

		foreach ( $attachment_ids as $attachment_id ) {
			if ( $attachment_id <= 0 ) {
				continue;
			}
			// Only register against existing attachments to avoid creating
			// stale rows pointing at deleted media.
			if ( 'attachment' !== get_post_type( $attachment_id ) ) {
				continue;
			}

			// Skip if this question is already registered as an owner.
			$existing = get_post_meta( $attachment_id, '_ppq_question_id', false );
			if ( is_array( $existing ) && in_array( $question_id, array_map( 'intval', $existing ), true ) ) {
				continue;
			}

			add_post_meta( $attachment_id, '_ppq_question_id', $question_id );
			++$added;
		}

		return $added;
	}

	/**
	 * Restore soft-deleted question
	 *
	 * @since 1.0.0
	 *
	 * @return bool|WP_Error True on success, WP_Error on failure.
	 */
	public function restore() {
		if ( empty( $this->id ) ) {
			return new WP_Error(
				'ppq_no_id',
				__( 'Cannot restore question without ID.', 'pressprimer-quiz' )
			);
		}

		$this->deleted_at = null;

		$result = parent::save(); // Use parent::save() to avoid triggering _updated hook.

		// Update question counts for all banks containing this question.
		if ( true === $result ) {
			$this->update_bank_counts();

			/**
			 * Fires after a question is restored from soft-delete.
			 *
			 * @since 2.0.0
			 *
			 * @param PressPrimer_Quiz_Question $question The question instance.
			 */
			do_action( 'pressprimer_quiz_question_restored', $this );
		}

		return $result;
	}

	/**
	 * Get current revision
	 *
	 * Lazy loads and returns the current question revision.
	 *
	 * @since 1.0.0
	 *
	 * @param bool $force Force refresh from database.
	 * @return PressPrimer_Quiz_Question_Revision|null Revision instance or null if not found.
	 */
	public function get_current_revision( $force = false ) {
		if ( null === $this->_current_revision || $force ) {
			if ( empty( $this->current_revision_id ) ) {
				return null;
			}

			if ( class_exists( 'PressPrimer_Quiz_Question_Revision' ) ) {
				$this->_current_revision = PressPrimer_Quiz_Question_Revision::get( $this->current_revision_id );
			}
		}

		return $this->_current_revision;
	}

	/**
	 * Get categories
	 *
	 * Lazy loads and returns question categories.
	 *
	 * @since 1.0.0
	 *
	 * @param bool $force Force refresh from database.
	 * @return array Array of category objects.
	 */
	public function get_categories( $force = false ) {
		if ( null === $this->_categories || $force ) {
			if ( class_exists( 'PressPrimer_Quiz_Category' ) ) {
				$this->_categories = PressPrimer_Quiz_Category::get_for_question( $this->id, 'category' );
			} else {
				$this->_categories = [];
			}
		}

		return $this->_categories;
	}

	/**
	 * Get tags
	 *
	 * Lazy loads and returns question tags.
	 *
	 * @since 1.0.0
	 *
	 * @param bool $force Force refresh from database.
	 * @return array Array of tag objects.
	 */
	public function get_tags( $force = false ) {
		if ( null === $this->_tags || $force ) {
			if ( class_exists( 'PressPrimer_Quiz_Category' ) ) {
				$this->_tags = PressPrimer_Quiz_Category::get_for_question( $this->id, 'tag' );
			} else {
				$this->_tags = [];
			}
		}

		return $this->_tags;
	}

	/**
	 * Set categories
	 *
	 * Updates the category relationships for this question.
	 *
	 * @since 1.0.0
	 *
	 * @param array $category_ids Array of category IDs.
	 * @return bool|WP_Error True on success, WP_Error on failure.
	 */
	public function set_categories( array $category_ids ) {
		if ( empty( $this->id ) ) {
			return new WP_Error(
				'ppq_no_id',
				__( 'Cannot set categories without question ID.', 'pressprimer-quiz' )
			);
		}

		global $wpdb;
		$tax_table = $wpdb->prefix . 'ppq_question_tax';

		// Start transaction
		$wpdb->query( 'START TRANSACTION' );

		// Remove existing category relationships
		$wpdb->delete(
			$tax_table,
			[
				'question_id' => $this->id,
				'taxonomy'    => 'category',
			],
			[ '%d', '%s' ]
		);

		// Add new relationships
		foreach ( $category_ids as $category_id ) {
			$category_id = absint( $category_id );
			if ( $category_id > 0 ) {
				$wpdb->insert(
					$tax_table,
					[
						'question_id' => $this->id,
						'category_id' => $category_id,
						'taxonomy'    => 'category',
					],
					[ '%d', '%d', '%s' ]
				);
			}
		}

		// Commit transaction
		$wpdb->query( 'COMMIT' );

		// Clear cache
		$this->_categories = null;

		// Update counts for all affected categories
		if ( class_exists( 'PressPrimer_Quiz_Category' ) ) {
			PressPrimer_Quiz_Category::update_counts( null );
		}

		return true;
	}

	/**
	 * Set tags
	 *
	 * Updates the tag relationships for this question.
	 *
	 * @since 1.0.0
	 *
	 * @param array $tag_ids Array of tag IDs.
	 * @return bool|WP_Error True on success, WP_Error on failure.
	 */
	public function set_tags( array $tag_ids ) {
		if ( empty( $this->id ) ) {
			return new WP_Error(
				'ppq_no_id',
				__( 'Cannot set tags without question ID.', 'pressprimer-quiz' )
			);
		}

		global $wpdb;
		$tax_table = $wpdb->prefix . 'ppq_question_tax';

		// Start transaction
		$wpdb->query( 'START TRANSACTION' );

		// Remove existing tag relationships
		$wpdb->delete(
			$tax_table,
			[
				'question_id' => $this->id,
				'taxonomy'    => 'tag',
			],
			[ '%d', '%s' ]
		);

		// Add new relationships
		foreach ( $tag_ids as $tag_id ) {
			$tag_id = absint( $tag_id );
			if ( $tag_id > 0 ) {
				$wpdb->insert(
					$tax_table,
					[
						'question_id' => $this->id,
						'category_id' => $tag_id,
						'taxonomy'    => 'tag',
					],
					[ '%d', '%d', '%s' ]
				);
			}
		}

		// Commit transaction
		$wpdb->query( 'COMMIT' );

		// Clear cache
		$this->_tags = null;

		// Update counts for all affected tags
		if ( class_exists( 'PressPrimer_Quiz_Category' ) ) {
			PressPrimer_Quiz_Category::update_counts( null );
		}

		return true;
	}

	/**
	 * Check if question is deleted
	 *
	 * @since 1.0.0
	 *
	 * @return bool True if deleted, false otherwise.
	 */
	public function is_deleted() {
		return ! empty( $this->deleted_at );
	}

	/**
	 * Update question counts for all banks containing this question
	 *
	 * Called when a question is soft-deleted or restored to keep
	 * bank question_count values accurate.
	 *
	 * @since 2.0.0
	 *
	 * @return void
	 */
	protected function update_bank_counts() {
		if ( empty( $this->id ) || ! class_exists( 'PressPrimer_Quiz_Bank' ) ) {
			return;
		}

		global $wpdb;

		$bank_questions_table = $wpdb->prefix . 'ppq_bank_questions';

		// Get all banks containing this question.
		$bank_ids = $wpdb->get_col(
			$wpdb->prepare(
				"SELECT DISTINCT bank_id FROM {$bank_questions_table} WHERE question_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				$this->id
			)
		);

		// Update each bank's question count.
		foreach ( $bank_ids as $bank_id ) {
			$bank = PressPrimer_Quiz_Bank::get( $bank_id );
			if ( $bank ) {
				$bank->update_question_count();
			}
		}
	}

	/**
	 * Get question usage count
	 *
	 * Returns the number of quizzes using this question.
	 *
	 * @since 1.0.0
	 *
	 * @return int Usage count.
	 */
	public function get_usage_count() {
		global $wpdb;

		$quiz_items_table = $wpdb->prefix . 'ppq_quiz_items';

		$count = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(DISTINCT quiz_id) FROM {$quiz_items_table} WHERE question_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				$this->id
			)
		);

		return absint( $count );
	}

	/**
	 * Find questions
	 *
	 * Extended find method with additional filters for questions.
	 *
	 * @since 1.0.0
	 *
	 * @param array $args Query arguments.
	 * @return array Array of question instances.
	 */
	public static function find_questions( array $args = [] ) {
		$defaults = [
			'type'              => null,
			'status'            => null,
			'difficulty_author' => null,
			'author_id'         => null,
			'exclude_deleted'   => true,
			'category_id'       => null,
			'tag_id'            => null,
		];

		$args = wp_parse_args( $args, $defaults );

		// Build where conditions
		$where = [];

		if ( ! empty( $args['type'] ) ) {
			$where['type'] = $args['type'];
		}

		if ( ! empty( $args['status'] ) ) {
			$where['status'] = $args['status'];
		}

		if ( ! empty( $args['difficulty_author'] ) ) {
			$where['difficulty_author'] = $args['difficulty_author'];
		}

		if ( ! empty( $args['author_id'] ) ) {
			$where['author_id'] = absint( $args['author_id'] );
		}

		if ( $args['exclude_deleted'] ) {
			// Note: PressPrimer_Quiz_Model::find() doesn't support IS NULL,
			// so we'll need to filter after or extend the query
			// For now, we'll handle this with a custom query
		}

		// Use parent find for simple cases
		if ( empty( $args['category_id'] ) && empty( $args['tag_id'] ) && $args['exclude_deleted'] ) {
			return self::find_with_deleted_filter( $where, $args );
		}

		return self::find( [ 'where' => $where ] );
	}

	/**
	 * Find questions excluding deleted
	 *
	 * @since 1.0.0
	 *
	 * @param array $where Where conditions.
	 * @param array $args  Additional arguments.
	 * @return array Array of question instances.
	 */
	private static function find_with_deleted_filter( array $where, array $args ) {
		global $wpdb;

		$table            = static::get_full_table_name();
		$queryable_fields = static::get_queryable_fields();

		// Build WHERE clause with field validation using %i placeholder for identifiers.
		// Start with deleted_at IS NULL (no placeholder needed for this static clause).
		$where_clauses  = [ 'deleted_at IS NULL' ];
		$prepare_values = [];

		foreach ( $where as $field => $value ) {
			// Validate field name against whitelist to prevent SQL injection.
			if ( ! in_array( $field, $queryable_fields, true ) ) {
				continue;
			}

			// Use %i for field name (identifier) and %s for value.
			$where_clauses[]  = '%i = %s';
			$prepare_values[] = $field;
			$prepare_values[] = $value;
		}

		// Build ORDER BY clause with field validation using %i placeholder.
		$order_by_field = sanitize_key( $args['order_by'] ?? 'id' );
		if ( ! in_array( $order_by_field, $queryable_fields, true ) ) {
			$order_by_field = 'id'; // Default to safe field.
		}
		// Validate order direction - only ASC or DESC allowed, default to DESC.
		$is_asc = 'ASC' === strtoupper( $args['order'] ?? 'DESC' );

		// Build LIMIT clause.
		$limit_sql    = '';
		$limit_values = [];
		if ( isset( $args['limit'] ) ) {
			$limit  = absint( $args['limit'] );
			$offset = absint( $args['offset'] ?? 0 );
			if ( $offset > 0 ) {
				$limit_sql      = 'LIMIT %d, %d';
				$limit_values[] = $offset;
				$limit_values[] = $limit;
			} else {
				$limit_sql      = 'LIMIT %d';
				$limit_values[] = $limit;
			}
		}

		// Build WHERE clause.
		$where_sql = 'WHERE ' . implode( ' AND ', $where_clauses );

		// Add order_by field to prepare values.
		$prepare_values[] = $order_by_field;
		// Add limit values.
		$prepare_values = array_merge( $prepare_values, $limit_values );

		// Build and prepare the query with hardcoded ORDER direction to satisfy security review.
		// Order direction is not interpolated - we use separate query strings for ASC vs DESC.
		if ( $is_asc ) {
			$query = $wpdb->prepare( "SELECT * FROM {$table} {$where_sql} ORDER BY %i ASC {$limit_sql}", $prepare_values );
		} else {
			$query = $wpdb->prepare( "SELECT * FROM {$table} {$where_sql} ORDER BY %i DESC {$limit_sql}", $prepare_values );
		}
		$rows = $wpdb->get_results( $query );

		// Convert to model instances.
		$results = [];
		if ( $rows ) {
			foreach ( $rows as $row ) {
				$results[] = static::from_row( $row );
			}
		}

		return $results;
	}
}
