<?php
/**
 * Database Query Abstract
 *
 * @package LifterLMS/Abstracts/Classes
 *
 * @since 3.8.0
 * @version 4.5.1
 */

defined( 'ABSPATH' ) || exit;

/**
 * Database Query abstract class.
 *
 * @since 3.8.0
 * @since 3.30.3 `is_last_page()` method returns `true` when no results are found.
 * @since 3.34.0 Sanitizes sort parameters.
 */
abstract class LLMS_Database_Query {

	/**
	 * Identify the extending query.
	 *
	 * @var string
	 */
	protected $id = 'database';

	/**
	 * Defines fields that can be sorted on via ORDER BY.
	 *
	 * @var array
	 */
	protected $allowed_sort_fields = null;

	/**
	 * Arguments: original merged into defaults.
	 *
	 * @var array
	 */
	protected $arguments = array();

	/**
	 * Default arguments before merging with original.
	 *
	 * @var  array
	 */
	protected $arguments_default = array();

	/**
	 * Original arguments before merging with defaults.
	 *
	 * @var array
	 */
	protected $arguments_original = array();

	/**
	 * Total number of results matching query parameters.
	 *
	 * @var integer
	 */
	public $found_results = 0;

	/**
	 * Maximum number of pages of results based off per_page & found_results.
	 *
	 * @var integer
	 */
	public $max_pages = 0;

	/**
	 * Number of results on the current page.
	 *
	 * @var integer
	 */
	public $number_results = 0;

	/**
	 * Array of query variables.
	 *
	 * @var array
	 */
	public $query_vars = array();

	/**
	 * Array of results retrieved by the query.
	 *
	 * @var array
	 */
	public $results = array();

	/**
	 * The raw SQL query.
	 *
	 * @var string
	 */
	protected $sql = '';

	/**
	 * Constructor.
	 *
	 * @since 3.8.0
	 *
	 * @param array $args Optional. Query arguments. Default empty array.
	 *                    When not provided the default arguments will be used.
	 * @return void
	 */
	public function __construct( $args = array() ) {

		$this->arguments_original = $args;
		$this->arguments_default  = $this->get_default_args();

		$this->setup_args();

		$this->query();

	}

	/**
	 * Escape and add quotes to a string, useful for array mapping when building queries.
	 *
	 * @since 3.8.0
	 *
	 * @param mixed $input Input data.
	 * @return string
	 */
	public function escape_and_quote_string( $input ) {
		return "'" . esc_sql( $input ) . "'";
	}

	/**
	 * Retrieve a query variable with an optional fallback / default.
	 *
	 * @since 3.8.0
	 *
	 * @param string $key     Variable key.
	 * @param mixed  $default Default value.
	 * @return mixed
	 */
	public function get( $key, $default = '' ) {

		if ( isset( $this->query_vars[ $key ] ) ) {
			return $this->query_vars[ $key ];
		}

		return $default;
	}

	/**
	 * Retrieve default arguments for the query.
	 *
	 * @since 3.8.0
	 * @since 4.5.1 Added new default arg `no_found_rows` set to false.
	 *
	 * @return array
	 */
	protected function get_default_args() {

		$args = array(
			'page'             => 1,
			'per_page'         => 25,
			'search'           => '',
			'sort'             => array(
				'id' => 'ASC',
			),
			'suppress_filters' => false,
			'no_found_rows'    => false,
		);

		if ( $this->get( 'suppress_filters' ) ) {
			return $args;
		}

		/**
		 * Filters the query default args.
		 *
		 * @since 3.8.0
		 *
		 * @param array $args Array of default arguments to set up the query with.
		 */
		return apply_filters( 'llms_db_query_get_default_args', $args );

	}

	/**
	 * Get a string used as filter names unique to the extending query.
	 *
	 * @since 3.8.0
	 *
	 * @TODO Deprecate.
	 *
	 * @param string $filter Filter name.
	 * @return string
	 */
	protected function get_filter( $filter ) {
		return 'llms_' . $this->id . '_query_' . $filter;
	}

	/**
	 * Retrieve an array of results for the given query.
	 *
	 * @since 3.8.0
	 * @since 4.5.1 Drop use of `this->get_filter('get_results')` in favor of `"llms_{$this->id}_query_get_results"`.
	 *
	 * @return array
	 */
	public function get_results() {

		if ( $this->get( 'suppress_filters' ) ) {
			return $this->results;
		}

		/**
		 * Filters the query results.
		 *
		 * The dynamic part of the filter `$this->id` identifies the extending query.
		 *
		 * @since 3.8.0
		 *
		 * @param array $results Array of results retrieved by the query.
		 */
		return apply_filters( "llms_{$this->id}_query_get_results", $this->results );

	}

	/**
	 * Get the number of results to skip for the query based on the current page and per_page vars.
	 *
	 * @since 3.8.0
	 *
	 * @return int
	 */
	protected function get_skip() {
		return absint( ( $this->get( 'page' ) - 1 ) * $this->get( 'per_page' ) );
	}

	/**
	 * Determine if the query has at least one result.
	 *
	 * @since 3.16.0
	 *
	 * @return bool
	 */
	public function has_results() {
		return ( $this->number_results > 0 );
	}

	/**
	 * Determine if we're on the first page of results.
	 *
	 * @since 3.8.0
	 * @since 3.14.0 Unknown.
	 *
	 * @return boolean
	 */
	public function is_first_page() {
		return ( 1 === absint( $this->get( 'page' ) ) );
	}

	/**
	 * Determine if we're on the last page of results.
	 *
	 * @since 3.8.0
	 * @since 3.30.3 Return true if there are no results.
	 *
	 * @return boolean
	 */
	public function is_last_page() {
		return ! $this->has_results() || ( absint( $this->get( 'page' ) ) === $this->max_pages );
	}

	/**
	 * Parse arguments needed for the query.
	 *
	 * @since 3.8.0
	 *
	 * @return void
	 */
	abstract protected function parse_args();

	/**
	 * Prepare the SQL for the query.
	 *
	 * @since 3.8.0
	 *
	 * @return string
	 */
	abstract protected function preprare_query();

	/**
	 * Execute a query.
	 *
	 * @since 3.8.0
	 * @since 4.5.1 Drop use of `$this->get_filter('prepare_query')` in favor of `"llms_{$this->id}_query_prepare_query"`.
	 *
	 * @return void
	 */
	public function query() {

		global $wpdb;

		$this->sql = $this->preprare_query();
		if ( ! $this->get( 'suppress_filters' ) ) {
			/**
			 * Filters the query SQL.
			 *
			 * The dynamic part of the filter `$this->id` identifies the extending query.
			 *
			 * @since 3.8.0
			 *
			 * @param string              $sql      The SQL query.
			 * @param LLMS_Database_Query $db_query The LLMS_Database_Query instance.
			 */
			$this->sql = apply_filters( "llms_{$this->id}_query_prepare_query", $this->sql, $this );
		}

		$this->results        = $wpdb->get_results( $this->sql ); // db call ok; no-cache ok.
		$this->number_results = count( $this->results );

		$this->set_found_results();

	}

	/**
	 * Sanitize input to ensure an array of absints.
	 *
	 * @since 3.15.0
	 * @since 3.24.0 Unknown.
	 *
	 * @param mixed $ids String/Int or array of strings/ints.
	 * @return array
	 */
	protected function sanitize_id_array( $ids = array() ) {

		if ( empty( $ids ) ) {
			$ids = array();
		}

		// Allow numeric strings & ints to be passed instead of an array.
		if ( ! is_array( $ids ) && is_numeric( $ids ) && $ids > 0 ) {
			$ids = array( $ids );
		}

		foreach ( $ids as $key => &$id ) {
			$id = absint( $id ); // Verify we have ints.
			if ( $id <= 0 ) { // Remove anything negative or 0.
				unset( $ids[ $key ] );
			}
		}

		return $ids;

	}

	/**
	 * Removes any invalid sort fields before preparing a query.
	 *
	 * @since 3.34.0
	 *
	 * @return void
	 */
	protected function sanitize_sort() {

		if ( empty( $this->allowed_sort_fields ) ) {
			return;
		}

		foreach ( (array) $this->get( 'sort' ) as $orderby => $order ) {

			if ( ! in_array( $orderby, $this->allowed_sort_fields, true ) || ! in_array( $order, array( 'ASC', 'DESC' ), true ) ) {

				unset( $this->arguments['sort'][ $orderby ] );

			}
		}

	}

	/**
	 * Sets a query variable.
	 *
	 * @since 3.8.0
	 *
	 * @param string $key Variable key.
	 * @param mixed  $val Variable value.
	 * @return void
	 */
	public function set( $key, $val ) {
		$this->query_vars[ $key ] = $val;
	}

	/**
	 * Set variables related to total number of results and pages possible with supplied arguments.
	 *
	 * @since 3.8.0
	 * @since 4.5.1 Bail early if the query arg `no_found_rows` is true, b/c no reason to calculate anything.
	 *
	 * @return void
	 */
	protected function set_found_results() {

		global $wpdb;

		// If no results, or found rows not required, bail early b/c no reason to calculate anything.
		if ( ! $this->number_results || $this->get( 'no_found_rows' ) ) {
			return;
		}

		$this->found_results = absint( $wpdb->get_var( 'SELECT FOUND_ROWS()' ) ); // db call ok; no-cache ok.
		$this->max_pages     = absint( ceil( $this->found_results / $this->get( 'per_page' ) ) );

	}

	/**
	 * Setup arguments prior to a query.
	 *
	 * @since 3.8.0
	 * @since 3.34.0 Sanitizes sort parameters.
	 * @since 4.5.1 Added filter `"llms_{$this->id}_query_parse_args"`.
	 *
	 * @return void
	 */
	protected function setup_args() {

		$this->arguments = wp_parse_args( $this->arguments_original, $this->arguments_default );

		$this->parse_args();

		if ( ! $this->get( 'suppress_filters' ) ) {
			/**
			 * Filters the parsed query arguments.
			 *
			 * The dynamic part of the filter `$this->id` identifies the extending query.
			 *
			 * @since 4.5.1
			 *
			 * @param array               $ars           The query parse arguments.
			 * @param LLMS_Database_Query $db_query      The LLMS_Database_Query instance.
			 * @param array               $original_args Original arguments before merging with defaults.
			 * @param array               $default_args  Default arguments before merging with original.
			 */
			$this->arguments = apply_filters( "llms_{$this->id}_query_parse_args", $this->arguments, $this, $this->arguments_original, $this->arguments_default );
		}

		foreach ( $this->arguments as $arg => $val ) {

			$this->set( $arg, $val );

		}

		$this->sanitize_sort();

	}

	/**
	 * Retrieve the prepared SQL for the SELECT clause.
	 *
	 * @since 4.5.1
	 *
	 * @param string $select_columns Optional. Columns to select. Default '*'.
	 * @return string
	 */
	protected function sql_select_columns( $select_columns = '*' ) {

		if ( ! $this->get( 'no_found_rows' ) ) {
			$select_columns = 'SQL_CALC_FOUND_ROWS ' . $select_columns;
		}

		if ( $this->get( 'suppress_filters' ) ) {
			return $select_columns;
		}

		/**
		 * Filters the query SELECT columns.
		 *
		 * The dynamic part of the filter `$this->id` identifies the extending query.
		 *
		 * @since 4.5.1
		 *
		 * @param string              $select_columns Columns to select.
		 * @param LLMS_Database_Query $db_query       Instance of LLMS_Database_Query.
		 */
		return apply_filters( "llms_{$this->id}_query_select_columns", $select_columns, $this );

	}

	/**
	 * Retrieve the prepared SQL for the LIMIT clause.
	 *
	 * @since 3.16.0
	 * @since 4.5.1 Drop use of `$this->get_filter('limit')` in favor of `"llms_{$this->id}_query_limit"`.
	 *
	 * @return string
	 */
	protected function sql_limit() {

		global $wpdb;

		$sql = $wpdb->prepare( 'LIMIT %d, %d', $this->get_skip(), $this->get( 'per_page' ) );

		/**
		 * Filters the query LIMIT clause.
		 *
		 * The dynamic part of the filter `$this->id` identifies the extending query.
		 *
		 * @since 3.16.0
		 *
		 * @param string              $sql      The LIMIT clause of the query.
		 * @param LLMS_Database_Query $db_query The LLMS_Database_Query instance.
		 */
		return apply_filters( "llms_{$this->id}_query_limit", $sql, $this );
	}

	/**
	 * Retrieve the prepared SQL for the ORDER BY clause.
	 *
	 * @since 3.8.0
	 * @since 3.34.0 Returns an empty string if no sort fields are available.
	 * @since 4.5.1 Drop use of `$this->get_filter('orderby')` in favor of `"llms_{$this->id}_query_orderby"`.
	 *
	 * @return string
	 */
	protected function sql_orderby() {

		$sql = '';

		$sort = $this->get( 'sort' );
		if ( $sort ) {

			$sql = 'ORDER BY';

			$comma = false;

			foreach ( $sort as $orderby => $order ) {
				$pre   = ( $comma ) ? ', ' : ' ';
				$sql  .= $pre . "{$orderby} {$order}";
				$comma = true;
			}
		}

		if ( $this->get( 'suppress_filters' ) ) {
			return $sql;
		}

		/**
		 * Filters the query ORDER BY clause.
		 *
		 * The dynamic part of the filter `$this->id` identifies the extending query.
		 *
		 * @since 3.8.0
		 *
		 * @param string              $sql      The ORDER BY clause of the query.
		 * @param LLMS_Database_Query $db_query The LLMS_Database_Query instance.
		 */
		return apply_filters( "llms_{$this->id}_query_orderby", $sql, $this );

	}

}
