<?php

/**
 * Imagify DB class. It reunites tools to work with the DB.
 *
 * @since  1.6.13
 * @author Grégory Viguier
 */
class Imagify_DB {

	/**
	 * Class version.
	 *
	 * @var string
	 */
	const VERSION = '1.0.1';

	/**
	 * Some hosts limit the number of JOINs in SQL queries, but we need them.
	 *
	 * @since  1.6.13
	 * @access public
	 * @author Grégory Viguier
	 */
	public static function unlimit_joins() {
		global $wpdb;
		static $done = false;

		if ( $done ) {
			return;
		}

		$done  = true;
		$query = 'SET SQL_BIG_SELECTS=1';

		/**
		 * Filter the SQL query allowing to remove the limit on JOINs.
		 *
		 * @since  1.6.13
		 * @author Grégory Viguier
		 *
		 * @param string|bool $query The query. False to prevent any query.
		 */
		$query = apply_filters( 'imagify_db_unlimit_joins_query', $query );

		if ( $query && is_string( $query ) ) {
			$wpdb->query( $query ); // WPCS: unprepared SQL ok.
		}
	}

	/**
	 * Change an array of values into a comma separated list, ready to be used in a `IN ()` clause.
	 *
	 * @since  1.6.13
	 * @access public
	 * @author Grégory Viguier
	 *
	 * @param  array $values An array of values.
	 * @return string        A comma separated list of values.
	 */
	public static function prepare_values_list( $values ) {
		$values = esc_sql( (array) $values );
		$values = array_map( [ __CLASS__, 'quote_string' ], $values );
		return implode( ',', $values );
	}

	/**
	 * Wrap a value in quotes, unless it's an integer.
	 *
	 * @since  1.6.13
	 * @access public
	 * @author Grégory Viguier
	 *
	 * @param  int|string $value A value.
	 * @return int|string
	 */
	public static function quote_string( $value ) {
		return is_numeric( $value ) ? $value : "'" . addcslashes( $value, "'" ) . "'";
	}

	/**
	 * First half of escaping for LIKE special characters % and _ before preparing for MySQL.
	 * Use this only before wpdb::prepare() or esc_sql().  Reversing the order is very bad for security.
	 *
	 * Example Prepared Statement:
	 *     $wild = '%';
	 *     $find = 'only 43% of planets';
	 *     $like = $wild . $wpdb->esc_like( $find ) . $wild;
	 *     $sql  = $wpdb->prepare( "SELECT * FROM $wpdb->posts WHERE post_content LIKE %s", $like );
	 *
	 * Example Escape Chain:
	 *     $sql  = esc_sql( $wpdb->esc_like( $input ) );
	 *
	 * @since  1.7
	 * @access public
	 * @author Grégory Viguier
	 *
	 * @param  string $text The raw text to be escaped. The input typed by the user should have no extra or deleted slashes.
	 * @return string       Text in the form of a LIKE phrase. The output is not SQL safe. Call $wpdb::prepare() or real_escape next.
	 */
	public static function esc_like( $text ) {
		global $wpdb;

		if ( method_exists( $wpdb, 'esc_like' ) ) {
			// Introduced in WP 4.0.0.
			return $wpdb->esc_like( $text );
		}

		return addcslashes( $text, '_%\\' );
	}

	/**
	 * Get Imagify mime types, ready to be used in a `IN ()` clause.
	 *
	 * @since  1.6.13
	 * @since  1.9 Added $type parameter.
	 * @access public
	 * @author Grégory Viguier
	 *
	 * @param  string $type One of 'image', 'not-image'. Any other value will return all mime types.
	 * @return string       A comma separated list of mime types.
	 */
	public static function get_mime_types( $type = null ) {
		static $mime_types = [];

		if ( empty( $type ) ) {
			$type = 'all';
		}

		if ( ! isset( $mime_types[ $type ] ) ) {
			$mime_types[ $type ] = self::prepare_values_list( imagify_get_mime_types( $type ) );
		}

		return $mime_types[ $type ];
	}

	/**
	 * Get post statuses related to attachments, ready to be used in a `IN ()` clause.
	 *
	 * @since  1.7
	 * @access public
	 * @author Grégory Viguier
	 *
	 * @return string A comma separated list of post statuses.
	 */
	public static function get_post_statuses() {
		static $statuses;

		if ( ! isset( $statuses ) ) {
			$statuses = self::prepare_values_list( imagify_get_post_statuses() );
		}

		return $statuses;
	}

	/**
	 * Get the SQL JOIN clause to use to get only attachments that have the required WP metadata.
	 * It returns an empty string if the database has no attachments without the required metadada.
	 * It also triggers Imagify_DB::unlimit_joins().
	 *
	 * @param string $id_field An ID field to match the metadata ID against in the JOIN clause.
	 *                          Default is the posts table `ID` field, using the `p` alias: `p.ID`.
	 *                          In case of "false" value or PEBKAC, fallback to the same field without alias.
	 * @param bool   $matching Set to false to get a query to fetch metas NOT matching the file extensions.
	 * @param bool   $test Test if the site has attachments without required metadata before returning the query. False to bypass the test and get the query anyway.
	 * @param string $special_join_conditions Special conditions to apply on the join.
	 *
	 * @return string
	 * @author Grégory Viguier
	 *
	 * @since  1.7
	 * @access public
	 */
	public static function get_required_wp_metadata_join_clause( $id_field = 'p.ID', $matching = true, $test = true, $special_join_conditions = '' ) {
		global $wpdb;

		if ( $test && ! imagify_has_attachments_without_required_metadata() ) {
			return '';
		}

		self::unlimit_joins();
		$clause = '';

		if ( ! $id_field || ! is_string( $id_field ) ) {
			$id_field = "$wpdb->posts.ID";
		}

		$join = $matching ? 'INNER' : 'LEFT';

		$first = true;

		foreach ( self::get_required_wp_metadata_aliases() as $meta_name => $alias ) {
			if ( $first ) {
				$first   = false;
				$clause .= "
			$join JOIN $wpdb->postmeta AS $alias
				ON ( $id_field = $alias.post_id AND $alias.meta_key = '$meta_name' $special_join_conditions )";
				continue;
			}
			$clause .= "
			$join JOIN $wpdb->postmeta AS $alias
				ON ( $id_field = $alias.post_id AND $alias.meta_key = '$meta_name' )";
		}

		return $clause;
	}


	/**
	 * Get the Sub query(exists) clause to use to get only attachments that have the required WP metadata.
	 * It returns an empty string if the database has no attachments without the required metadada.
	 *
	 * @param string $id_field An ID field to match the metadata ID against in the WHERE clause.
	 *                          Default is the posts table `ID` field, using the `p` alias: `p.ID`.
	 * @param bool   $test Test if the site has attachments without required metadata before returning the query. False to bypass the test and get the query anyway.
	 *
	 * @return string
	 */
	public static function get_required_wp_metadata_exist_clause( $id_field = 'p.ID', $test = true ) {
		global $wpdb;

		if ( $test && ! imagify_has_attachments_without_required_metadata() ) {
			return '';
		}

		self::unlimit_joins();
		$clause = '';

		if ( ! $id_field || ! is_string( $id_field ) ) {
			$id_field = "$wpdb->posts.ID";
		}
		$additional_clause = self::get_required_exist_wp_metadata_where_clause(
			[
				'matching' => false,
				'test'     => false,
			]
		);

		$first = true;

		foreach ( self::get_required_wp_metadata_aliases() as $meta_name => $alias ) {
			if ( $first ) {
				$first   = false;
				$clause .= "
                    EXISTS(
                        SELECT 1 FROM $wpdb->postmeta AS $alias WHERE
                        $alias.post_id = $id_field AND $alias.meta_key = '$meta_name'
                        $additional_clause
                    )
                ";
				continue;
			}

			$clause .= "
                    OR NOT EXISTS (
                    SELECT 1 FROM $wpdb->postmeta AS $alias WHERE
                    $alias.post_id = $id_field AND $alias.meta_key = '$meta_name')";
		}

		return "AND( $clause  )";
	}

	/**
	 * Get the SQL part to be used in a WHERE clause, to get only attachments that have (in)valid '_wp_attached_file' and '_wp_attachment_metadata' metadatas.
	 * It returns an empty string if the database has no attachments without the required metadada.
	 *
	 * @since  1.7
	 * @since  1.7.1.2 Use a single $arg parameter instead of 3. New $prepared parameter.
	 * @access public
	 * @author Grégory Viguier
	 *
	 * @param  array $args {
	 *                   Optional. An array of arguments.
	 *
	 *                   string $aliases  The aliases to use for the meta values.
	 *                   bool   $matching Set to false to get a query to fetch invalid metas.
	 *                   bool   $test     Test if the site has attachments without required metadata before returning the query. False to bypass the test and get the query anyway.
	 *                   bool   $prepared Set to true if the query will be prepared with using $wpdb->prepare().
	 * }.
	 * @return string A query.
	 */
	public static function get_required_wp_metadata_where_clause( $args = [] ) {
		static $query = [];

		$args = imagify_merge_intersect(
			$args,
			[
				'aliases'  => [],
				'matching' => true,
				'test'     => true,
				'prepared' => false,
			]
		);

		list( $aliases, $matching, $test, $prepared ) = array_values( $args );

		if ( $test && ! imagify_has_attachments_without_required_metadata() ) {
			return '';
		}

		if ( $aliases && is_string( $aliases ) ) {
			$aliases = [
				'_wp_attached_file' => $aliases,
			];
		} elseif ( ! is_array( $aliases ) ) {
			$aliases = [];
		}

		$aliases = imagify_merge_intersect( $aliases, self::get_required_wp_metadata_aliases() );
		$key     = implode( '|', $aliases ) . '|' . (int) $matching;

		if ( isset( $query[ $key ] ) ) {
			return $prepared ? str_replace( '%', '%%', $query[ $key ] ) : $query[ $key ];
		}

		unset( $args['prepared'] );
		$alias_1    = $aliases['_wp_attached_file'];
		$alias_2    = $aliases['_wp_attachment_metadata'];
		$extensions = self::get_extensions_where_clause( $args );

		if ( $matching ) {
			$query[ $key ] = "AND $alias_1.meta_value NOT LIKE '%://%' AND $alias_1.meta_value NOT LIKE '_:\\\\\%' AND $extensions";
		} else {
			$query[ $key ] = "AND ( $alias_2.meta_value IS NULL OR $alias_1.meta_value IS NULL OR $alias_1.meta_value LIKE '%://%' OR $alias_1.meta_value LIKE '_:\\\\\%' AND $extensions )";
		}

		return $prepared ? str_replace( '%', '%%', $query[ $key ] ) : $query[ $key ];
	}

	/**
	 * Get the SQL part to be used in a WHERE clause, to get only attachments that have (in)valid '_wp_attached_file' and '_wp_attachment_metadata' metadatas.
	 *  It returns an empty string if the database has no attachments without the required metadada.
	 *
	 * @param array $args {
	 *                    Optional. An array of arguments.
	 *
	 *                    string $aliases  The aliases to use for the meta values.
	 *                    bool   $matching Set to false to get a query to fetch invalid metas.
	 *                    bool   $test     Test if the site has attachments without required metadata before returning the query. False to bypass the test and get the query anyway.
	 *                    bool   $prepared Set to true if the query will be prepared with using $wpdb->prepare().
	 *  }.
	 * @return string A query.
	 */
	public static function get_required_exist_wp_metadata_where_clause( $args = [] ) {
		static $query = [];

		$args = imagify_merge_intersect(
			$args,
			[
				'aliases'  => [],
				'matching' => true,
				'test'     => true,
				'prepared' => false,
			]
		);

		list( $aliases, $matching, $test, $prepared ) = array_values( $args );

		if ( $test && ! imagify_has_attachments_without_required_metadata() ) {
			return '';
		}

		if ( $aliases && is_string( $aliases ) ) {
			$aliases = [
				'_wp_attached_file' => $aliases,
			];
		} elseif ( ! is_array( $aliases ) ) {
			$aliases = [];
		}

		$aliases = imagify_merge_intersect( $aliases, self::get_required_wp_metadata_aliases() );
		$key     = implode( '|', $aliases ) . '|' . (int) $matching;

		if ( isset( $query[ $key ] ) ) {
			return $prepared ? str_replace( '%', '%%', $query[ $key ] ) : $query[ $key ];
		}

		unset( $args['prepared'] );
		$alias_1    = $aliases['_wp_attached_file'];
		$extensions = self::get_extensions_where_clause( $args );

		if ( $matching ) {
			$query[ $key ] = "AND $alias_1.meta_value NOT LIKE '%://%' AND $alias_1.meta_value NOT LIKE '_:\\\\\%' OR NOT ( $extensions )";
		} else {
			$query[ $key ] = "AND ( $alias_1.meta_value LIKE '%://%' OR $alias_1.meta_value LIKE '_:\\\\\%' OR NOT ( $extensions ) )";
		}

		return $prepared ? str_replace( '%', '%%', $query[ $key ] ) : $query[ $key ];
	}

	/**
	 * Prepare query arguments.
	 *
	 * @param array $args {
	 *                     Optional. An array of arguments.
	 *
	 *                     string $aliases  The aliases to use for the meta values.
	 *                     bool   $matching Set to false to get a query to fetch invalid metas.
	 *                     bool   $test     Test if the site has attachments without required metadata before returning the query. False to bypass the test and get the query anyway.
	 *                     bool   $prepared Set to true if the query will be prepared with using $wpdb->prepare().
	 *   }.
	 *
	 * @return array
	 */
	private function prepare_query_args( $args ) {
		return imagify_merge_intersect(
			$args,
			[
				'aliases'  => [],
				'matching' => true,
				'test'     => true,
				'prepared' => false,
			]
		);
	}

	/**
	 * Generate query.
	 *
	 * @param bool   $matching Matching.
	 * @param string $alias Query alias.
	 * @param string $regex Query Regex.
	 *
	 * @return string
	 */
	private function generate_query( $matching, $alias, $regex ) {
		if ( $matching ) {
			return "REVERSE (LOWER( $alias.meta_value )) REGEXP '$regex'";
		}

		return "REVERSE (LOWER( $alias.meta_value )) NOT REGEXP '$regex'";
	}

	/**
	 * Get the SQL part to be used in a WHERE clause, to get only attachments that have a valid file extensions.
	 * It returns an empty string if the database has no attachments without the required metadada.
	 *
	 * @since  1.7
	 * @since  1.7.1.2 Use a single $arg parameter instead of 3. New $prepared parameter.
	 * @access public
	 * @author Grégory Viguier
	 *
	 * @param  array $args {
	 *                   Optional. An array of arguments.
	 *
	 *                   string $alias    The alias to use for the meta value.
	 *                   bool   $matching Set to false to get a query to fetch metas NOT matching the file extensions.
	 *                   bool   $test     Test if the site has attachments without required metadata before returning the query. False to bypass the test and get the query anyway.
	 *                   bool   $prepared Set to true if the query will be prepared with using $wpdb->prepare().
	 * }.
	 * @return string A query.
	 */
	public static function get_extensions_where_clause( $args = false ) {
		static $extensions;
		static $query = [];

		$instance = new self();

		$args = $instance->prepare_query_args( $args );

		list( $alias, $matching, $test, $prepared ) = array_values( $args );

		if ( $test && ! imagify_has_attachments_without_required_metadata() ) {
			return '';
		}

		if ( ! isset( $extensions ) ) {
			$extensions = array_keys( imagify_get_mime_types() );
			$extensions = implode( '|', $extensions );
			$extensions = explode( '|', $extensions );
			$extensions = array_map(
				function ( $ex ) {
					return strrev( $ex );
				},
				$extensions
			);
		}

		if ( ! $alias ) {
			$alias = self::get_required_wp_metadata_aliases();
			$alias = $alias['_wp_attached_file'];
		}

		$key = $alias . '|' . (int) $matching;

		if ( isset( $query[ $key ] ) ) {
			return $prepared ? str_replace( '%', '%%', $query[ $key ] ) : $query[ $key ];
		}

		$regex = '^' . implode( '\..*|^', $extensions ) . '\..*';

		$query[ $key ] = $instance->generate_query( $matching, $alias, $regex );

		return $prepared ? str_replace( '%', '%%', $query[ $key ] ) : $query[ $key ];
	}

	/**
	 * Get the aliases used for the metas in self::get_required_wp_metadata_join_clause(), self::get_required_wp_metadata_where_clause(), and self::get_extensions_where_clause().
	 *
	 * @since  1.7
	 * @access public
	 * @author Grégory Viguier
	 *
	 * @return array An array with the meta name as key and its alias as value.
	 */
	public static function get_required_wp_metadata_aliases() {
		return [
			'_wp_attached_file'       => 'imrwpmt1',
			'_wp_attachment_metadata' => 'imrwpmt2',
		];
	}

	/**
	 * Combine two arrays with some specific keys.
	 * We use this function to combine the result of 2 SQL queries.
	 *
	 * @since  1.6.13
	 * @access public
	 * @author Grégory Viguier
	 *
	 * @param  array $keys            An array of keys.
	 * @param  array $values          An array of arrays like array( 'id' => id, 'value' => value ).
	 * @param  int   $keep_keys_order Set to true to return an array ordered like $keys instead of $values.
	 * @return array                  The combined arrays.
	 */
	public static function combine_query_results( $keys, $values, $keep_keys_order = false ) {
		if ( ! $keys || ! $values ) {
			return [];
		}

		$result = [];
		$keys   = array_flip( $keys );

		foreach ( $values as $v ) {
			if ( isset( $keys[ $v['id'] ] ) ) {
				$result[ $v['id'] ] = $v['value'];
			}
		}

		if ( $keep_keys_order ) {
			$keys = array_intersect_key( $keys, $result );
			return array_replace( $keys, $result );
		}

		return $result;
	}

	/**
	 * A helper to retrieve all values from one or several post metas, given a list of post IDs.
	 * The $wpdb cache is flushed to save memory.
	 *
	 * @since  1.6.13
	 * @access public
	 * @author Grégory Viguier
	 *
	 * @param  array $metas An array of meta names like:
	 *                      array(
	 *                          'key1' => 'meta_name_1',
	 *                          'key2' => 'meta_name_2',
	 *                          'key3' => 'meta_name_3',
	 *                      )
	 *                      If a key contains 'data', the results will be unserialized.
	 * @param  array $ids   An array of post IDs.
	 * @return array        An array of arrays of results like:
	 *                      array(
	 *                          'key1' => array( post_id_1 => 'result_1', post_id_2 => 'result_2', post_id_3 => 'result_3' ),
	 *                          'key2' => array( post_id_1 => 'result_4', post_id_3 => 'result_5' ),
	 *                          'key3' => array( post_id_1 => 'result_6', post_id_2 => 'result_7' ),
	 *                      )
	 */
	public static function get_metas( $metas, $ids ) {
		global $wpdb;

		if ( ! $ids ) {
			return array_fill_keys( array_keys( $metas ), [] );
		}

		$sql_ids = implode( ',', $ids );

		foreach ( $metas as $result_name => $meta_name ) {
			$metas[ $result_name ] = $wpdb->get_results( // WPCS: unprepared SQL ok.
				"SELECT pm.post_id as id, pm.meta_value as value
				FROM $wpdb->postmeta as pm
				WHERE pm.meta_key = '$meta_name'
					AND pm.post_id IN ( $sql_ids )
				ORDER BY pm.post_id DESC",
				ARRAY_A
			);

			$wpdb->flush();
			$metas[ $result_name ] = self::combine_query_results( $ids, $metas[ $result_name ], true );

			if ( strpos( $result_name, 'data' ) !== false ) {
				$metas[ $result_name ] = array_map( 'maybe_unserialize', $metas[ $result_name ] );
			}
		}

		return $metas;
	}

	/**
	 * Create/Upgrade the table in the database.
	 *
	 * @since  1.7
	 * @access public
	 * @author Grégory Viguier
	 *
	 * @param  string $table_name   The (prefixed) table name.
	 * @param  string $schema_query Query representing the table schema.
	 * @return bool                 True on success. False otherwise.
	 */
	public static function create_table( $table_name, $schema_query ) {
		global $wpdb;

		require_once ABSPATH . 'wp-admin/includes/upgrade.php';

		$wpdb->hide_errors();

		$schema_query    = trim( $schema_query );
		$charset_collate = $wpdb->get_charset_collate();

		dbDelta( "CREATE TABLE $table_name ($schema_query) $charset_collate;" );

		return empty( $wpdb->last_error ) && self::table_exists( $table_name );
	}

	/**
	 * Tell if the given table exists.
	 *
	 * @since  1.7
	 * @access public
	 * @author Grégory Viguier
	 *
	 * @param  string $table_name Full name of the table (with DB prefix).
	 * @return bool
	 */
	public static function table_exists( $table_name ) {
		global $wpdb;

		$escaped_table = self::esc_like( $table_name );
		$result        = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $escaped_table ) );

		return $result === $table_name;
	}

	/**
	 * Cache transients used for optimization process locks.
	 *
	 * @since  1.9
	 * @access public
	 * @author Grégory Viguier
	 *
	 * @param string $context   The context.
	 * @param array  $media_ids The media IDs.
	 */
	public static function cache_process_locks( $context, $media_ids ) {
		global $wpdb;

		if ( ! $context || ! $media_ids || wp_using_ext_object_cache() ) {
			return;
		}

		// Sanitize the IDs.
		$media_ids = array_filter( $media_ids );
		$media_ids = array_unique( $media_ids );

		if ( ! $media_ids ) {
			return;
		}

		$context_instance   = imagify_get_context( $context );
		$context            = $context_instance->get_name();
		$process_class_name = imagify_get_optimization_process_class_name( $context );
		$transient_name     = sprintf( $process_class_name::LOCK_NAME, $context, '%' );
		$is_network_wide    = $context_instance->is_network_wide();

		// Do 1 DB query per context (and cache results) before doing 1 get_transient() (2 DB queries) per media ID.
		$prefix = $is_network_wide ? '_site_transient_' : '_transient_';

		if ( $is_network_wide && is_multisite() ) {
			$network_id     = function_exists( 'get_current_network_id' ) ? get_current_network_id() : (int) $wpdb->siteid;
			$cache_prefix   = "$network_id:";
			$notoptions_key = "$network_id:notoptions";
			$cache_group    = 'site-options';
			$results        = $wpdb->get_results(
				$wpdb->prepare(
					"SELECT meta_key as name, meta_value as value FROM $wpdb->sitemeta WHERE ( meta_key LIKE %s OR meta_key LIKE %s ) AND site_id = %d",
					$prefix . $transient_name,
					$prefix . 'timeout_' . $transient_name,
					$network_id
				),
				OBJECT_K
			); // WPCS: unprepared SQL ok.
		} else {
			$cache_prefix   = '';
			$notoptions_key = 'notoptions';
			$cache_group    = 'options';
			$results        = $wpdb->get_results(
				$wpdb->prepare(
					"SELECT option_name as name, option_value as value FROM $wpdb->options WHERE ( option_name LIKE %s OR option_name LIKE %s )",
					$prefix . $transient_name,
					$prefix . 'timeout_' . $transient_name
				),
				OBJECT_K
			); // WPCS: unprepared SQL ok.
		}

		$not_exist = [];

		foreach ( [ '', 'timeout_' ] as $maybe_timeout ) {
			foreach ( $media_ids as $id ) {
				$option_name = $prefix . $maybe_timeout . str_replace( '%', $id, $transient_name );

				if ( isset( $results[ $option_name ] ) ) {
					// Cache the value.
					$value = $results[ $option_name ]->value;
					$value = maybe_unserialize( $value );
					wp_cache_set( "$cache_prefix$option_name", $value, $cache_group );
				} else {
					// No value.
					$not_exist[ $option_name ] = true;
				}
			}
		}

		if ( ! $not_exist ) {
			return;
		}

		// Cache the options that don't exist in the DB.
		$notoptions = wp_cache_get( $notoptions_key, $cache_group );
		$notoptions = is_array( $notoptions ) ? $notoptions : [];
		$notoptions = array_merge( $notoptions, $not_exist );

		wp_cache_set( $notoptions_key, $notoptions, $cache_group );
	}
}
