<?php

use WP_CLI\Traverser\RecursiveDataStructureTraverser;
use WP_CLI\Utils;

/**
 * Adds, gets, and deletes entries in the WordPress Transient Cache.
 *
 * By default, the transient cache uses the WordPress database to persist values
 * between requests. On a single site installation, values are stored in the
 * `wp_options` table. On a multisite installation, values are stored in the
 * `wp_options` or the `wp_sitemeta` table, depending on use of the `--network`
 * flag.
 *
 * When a persistent object cache drop-in is installed (e.g. Redis or Memcached),
 * the transient cache skips the database and simply wraps the WP Object Cache.
 *
 * ## EXAMPLES
 *
 *     # Set transient.
 *     $ wp transient set sample_key "test data" 3600
 *     Success: Transient added.
 *
 *     # Get transient.
 *     $ wp transient get sample_key
 *     test data
 *
 *     # Delete transient.
 *     $ wp transient delete sample_key
 *     Success: Transient deleted.
 *
 *     # Delete expired transients.
 *     $ wp transient delete --expired
 *     Success: 12 expired transients deleted from the database.
 *
 *     # Delete all transients.
 *     $ wp transient delete --all
 *     Success: 14 transients deleted from the database.
 */
class Transient_Command extends WP_CLI_Command {

	/**
	 * Gets a transient value.
	 *
	 * For a more complete explanation of the transient cache, including the
	 * network|site cache, please see docs for `wp transient`.
	 *
	 * ## OPTIONS
	 *
	 * <key>
	 * : Key for the transient.
	 *
	 * [--format=<format>]
	 * : Render output in a particular format.
	 * ---
	 * default: table
	 * options:
	 *   - table
	 *   - csv
	 *   - json
	 *   - yaml
	 * ---
	 *
	 * [--network]
	 * : Get the value of a network|site transient. On single site, this is
	 * is a specially-named cache key. On multisite, this is a global cache
	 * (instead of local to the site).
	 *
	 * ## EXAMPLES
	 *
	 *     $ wp transient get sample_key
	 *     test data
	 *
	 *     $ wp transient get random_key
	 *     Warning: Transient with key "random_key" is not set.
	 */
	public function get( $args, $assoc_args ) {
		list( $key ) = $args;

		$func  = Utils\get_flag_value( $assoc_args, 'network' ) ? 'get_site_transient' : 'get_transient';
		$value = $func( $key );

		if ( false === $value ) {
			WP_CLI::warning( 'Transient with key "' . $key . '" is not set.' );
			exit;
		}

		WP_CLI::print_value( $value, $assoc_args );
	}

	/**
	 * Sets a transient value.
	 *
	 * `<expiration>` is the time until expiration, in seconds.
	 *
	 * For a more complete explanation of the transient cache, including the
	 * network|site cache, please see docs for `wp transient`.
	 *
	 * ## OPTIONS
	 *
	 * <key>
	 * : Key for the transient.
	 *
	 * <value>
	 * : Value to be set for the transient.
	 *
	 * [<expiration>]
	 * : Time until expiration, in seconds.
	 *
	 * [--network]
	 * : Set the value of a network|site transient. On single site, this is
	 * is a specially-named cache key. On multisite, this is a global cache
	 * (instead of local to the site).
	 *
	 * ## EXAMPLES
	 *
	 *     $ wp transient set sample_key "test data" 3600
	 *     Success: Transient added.
	 */
	public function set( $args, $assoc_args ) {
		list( $key, $value ) = $args;

		$expiration = Utils\get_flag_value( $args, 2, 0 );

		$func = Utils\get_flag_value( $assoc_args, 'network' ) ? 'set_site_transient' : 'set_transient';
		if ( $func( $key, $value, $expiration ) ) {
			WP_CLI::success( 'Transient added.' );
		} else {
			WP_CLI::error( 'Transient could not be set.' );
		}
	}

	/**
	 * Deletes a transient value.
	 *
	 * For a more complete explanation of the transient cache, including the
	 * network|site cache, please see docs for `wp transient`.
	 *
	 * ## OPTIONS
	 *
	 * [<key>]
	 * : Key for the transient.
	 *
	 * [--network]
	 * : Delete the value of a network|site transient. On single site, this is
	 * is a specially-named cache key. On multisite, this is a global cache
	 * (instead of local to the site).
	 *
	 * [--all]
	 * : Delete all transients.
	 *
	 * [--expired]
	 * : Delete all expired transients.
	 *
	 * ## EXAMPLES
	 *
	 *     # Delete transient.
	 *     $ wp transient delete sample_key
	 *     Success: Transient deleted.
	 *
	 *     # Delete expired transients.
	 *     $ wp transient delete --expired
	 *     Success: 12 expired transients deleted from the database.
	 *
	 *     # Delete expired site transients.
	 *     $ wp transient delete --expired --network
	 *     Success: 1 expired transient deleted from the database.
	 *
	 *     # Delete all transients.
	 *     $ wp transient delete --all
	 *     Success: 14 transients deleted from the database.
	 *
	 *     # Delete all site transients.
	 *     $ wp transient delete --all --network
	 *     Success: 2 transients deleted from the database.
	 *
	 *     # Delete all transients in a multisite.
	 *     $ wp transient delete --all --network && wp site list --field=url | xargs -n1 -I % wp --url=% transient delete --all
	 */
	public function delete( $args, $assoc_args ) {
		$key = ( ! empty( $args ) ) ? $args[0] : null;

		$all     = Utils\get_flag_value( $assoc_args, 'all' );
		$expired = Utils\get_flag_value( $assoc_args, 'expired' );
		$network = Utils\get_flag_value( $assoc_args, 'network' );

		if ( true === $all ) {
			$this->delete_all( $network );
			return;
		} elseif ( true === $expired ) {
			$this->delete_expired( $network );
			return;
		}

		if ( ! $key ) {
			WP_CLI::error( 'Please specify transient key, or use --all or --expired.' );
		}

		$func = $network ? 'delete_site_transient' : 'delete_transient';

		if ( $func( $key ) ) {
			WP_CLI::success( 'Transient deleted.' );
		} else {
			$func = Utils\get_flag_value( $assoc_args, 'network' ) ? 'get_site_transient' : 'get_transient';
			if ( $func( $key ) ) {
				WP_CLI::error( 'Transient was not deleted even though the transient appears to exist.' );
			} else {
				WP_CLI::warning( 'Transient was not deleted; however, the transient does not appear to exist.' );
			}
		}
	}

	/**
	 * Determines the type of transients implementation.
	 *
	 * Indicates whether the transients API is using an object cache or the
	 * database.
	 *
	 * For a more complete explanation of the transient cache, including the
	 * network|site cache, please see docs for `wp transient`.
	 *
	 * ## EXAMPLES
	 *
	 *     $ wp transient type
	 *     Transients are saved to the database.
	 */
	public function type() {
		if ( wp_using_ext_object_cache() ) {
			$message = 'Transients are saved to the object cache.';
		} else {
			$message = 'Transients are saved to the database.';
		}

		WP_CLI::line( $message );
	}

	/**
	 * Lists transients and their values.
	 *
	 * ## OPTIONS
	 *
	 * [--search=<pattern>]
	 * : Use wildcards ( * and ? ) to match transient name.
	 *
	 * [--exclude=<pattern>]
	 * : Pattern to exclude. Use wildcards ( * and ? ) to match transient name.
	 *
	 * [--network]
	 * : Get the values of network|site transients. On single site, this is
	 * a specially-named cache key. On multisite, this is a global cache
	 * (instead of local to the site).
	 *
	 * [--unserialize]
	 * : Unserialize transient values in output.
	 *
	 * [--human-readable]
	 * : Human-readable output for expirations.
	 *
	 * [--fields=<fields>]
	 * : Limit the output to specific object fields.
	 *
	 * [--format=<format>]
	 * : The serialization format for the value.
	 * ---
	 * default: table
	 * options:
	 *   - table
	 *   - json
	 *   - csv
	 *   - count
	 *   - yaml
	 * ---
	 *
	 * ## AVAILABLE FIELDS
	 *
	 * This field will be displayed by default for each matching option:
	 *
	 * * name
	 * * value
	 * * expiration
	 *
	 * ## EXAMPLES
	 *
	 *     # List all transients
	 *     $ wp transient list
	*      +------+-------+---------------+
	*      | name | value | expiration    |
	*      +------+-------+---------------+
	*      | foo  | bar   | 39 mins       |
	*      | foo2 | bar2  | no expiration |
	*      | foo3 | bar2  | expired       |
	*      | foo4 | bar4  | 4 hours       |
	*      +------+-------+---------------+
	 *
	 * @subcommand list
	 */
	public function list_( $args, $assoc_args ) {
		global $wpdb;

		if ( wp_using_ext_object_cache() ) {
			WP_CLI::warning( 'Transients are stored in an external object cache, and this command only shows those stored in the database.' );
		}

		$network        = Utils\get_flag_value( $assoc_args, 'network', false );
		$unserialize    = Utils\get_flag_value( $assoc_args, 'unserialize', false );
		$human_readable = Utils\get_flag_value( $assoc_args, 'human-readable', false );

		$fields = array( 'name', 'value', 'expiration' );
		if ( isset( $assoc_args['fields'] ) ) {
			$fields = explode( ',', $assoc_args['fields'] );
		}

		$pattern = '%';
		$exclude = '';
		if ( isset( $assoc_args['search'] ) ) {
			$pattern = Utils\esc_like( $assoc_args['search'] );
			// Substitute wildcards.
			$pattern = str_replace(
				array( '*', '?' ),
				array( '%', '_' ),
				$pattern
			);
		}
		if ( isset( $assoc_args['exclude'] ) ) {
			$exclude = Utils\esc_like( $assoc_args['exclude'] );
			// Substitute wildcards.
			$exclude = str_replace(
				array( '*', '?' ),
				array( '%', '_' ),
				$exclude
			);
		}

		if ( $network ) {
			if ( is_multisite() ) {
				$where  = $wpdb->prepare(
					'WHERE `meta_key` LIKE %s',
					Utils\esc_like( '_site_transient_' ) . $pattern
				);
				$where .= $wpdb->prepare(
					' AND meta_key NOT LIKE %s',
					Utils\esc_like( '_site_transient_timeout_' ) . '%'
				);
				if ( $exclude ) {
					$where .= $wpdb->prepare(
						' AND meta_key NOT LIKE %s',
						Utils\esc_like( '_site_transient_' ) . $exclude
					);
				}

				$query = "SELECT `meta_key` as `name`, `meta_value` as `value` FROM {$wpdb->sitemeta} {$where}";
			} else {
				$where  = $wpdb->prepare(
					'WHERE `option_name` LIKE %s',
					Utils\esc_like( '_site_transient_' ) . $pattern
				);
				$where .= $wpdb->prepare(
					' AND option_name NOT LIKE %s',
					Utils\esc_like( '_site_transient_timeout_' ) . '%'
				);
				if ( $exclude ) {
					$where .= $wpdb->prepare(
						' AND option_name NOT LIKE %s',
						Utils\esc_like( '_site_transient_' ) . $exclude
					);
				}

				$query = "SELECT `option_name` as `name`, `option_value` as `value` FROM {$wpdb->options} {$where}";
			}
		} else {
			$where  = $wpdb->prepare(
				'WHERE `option_name` LIKE %s',
				Utils\esc_like( '_transient_' ) . $pattern
			);
			$where .= $wpdb->prepare(
				' AND option_name NOT LIKE %s',
				Utils\esc_like( '_transient_timeout_' ) . '%'
			);
			if ( $exclude ) {
				$where .= $wpdb->prepare(
					' AND option_name NOT LIKE %s',
					Utils\esc_like( '_transient_' ) . $exclude
				);
			}

			$query = "SELECT `option_name` as `name`, `option_value` as `value` FROM {$wpdb->options} {$where}";
		}

		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Prepared properly above.
		$results = $wpdb->get_results( $query );

		foreach ( $results as $result ) {
			$result->name       = str_replace( array( '_site_transient_', '_transient_' ), '', $result->name );
			$result->expiration = $this->get_transient_expiration( $result->name, $network, $human_readable );

			if ( $unserialize ) {
				$result->value = maybe_unserialize( $result->value );
			}
		}

		$formatter = new \WP_CLI\Formatter(
			$assoc_args,
			$fields
		);
		$formatter->display_items( $results );
	}

	/**
	 * Get a nested value from a transient.
	 *
	 * ## OPTIONS
	 *
	 * <key>
	 * : Key for the transient.
	 *
	 * <key-path>...
	 * : The name(s) of the keys within the value to locate the value to pluck.
	 *
	 * [--format=<format>]
	 * : The output format of the value.
	 * ---
	 * default: plaintext
	 * options:
	 *   - plaintext
	 *   - json
	 *   - yaml
	 * ---
	 *
	 * [--network]
	 * : Get the value of a network|site transient. On single site, this is
	 * a specially-named cache key. On multisite, this is a global cache
	 * (instead of local to the site).
	 */
	public function pluck( $args, $assoc_args ) {
		list( $key ) = $args;

		$func  = Utils\get_flag_value( $assoc_args, 'network' ) ? 'get_site_transient' : 'get_transient';
		$value = $func( $key );

		if ( false === $value ) {
			WP_CLI::warning( 'Transient with key "' . $key . '" is not set.' );
			exit;
		}

		$key_path = array_map(
			function ( $key ) {
				if ( is_numeric( $key ) && ( (string) intval( $key ) === $key ) ) {
					return (int) $key;
				}
				return $key;
			},
			array_slice( $args, 1 )
		);

		$traverser = new RecursiveDataStructureTraverser( $value );

		try {
			$value = $traverser->get( $key_path );
		} catch ( \Exception $e ) {
			die( 1 );
		}

		WP_CLI::print_value( $value, $assoc_args );
	}

	/**
	 * Update a nested value from a transient.
	 *
	 * ## OPTIONS
	 *
	 * <action>
	 * : Patch action to perform.
	 * ---
	 * options:
	 *   - insert
	 *   - update
	 *   - delete
	 * ---
	 *
	 * <key>
	 * : Key for the transient.
	 *
	 * <key-path>...
	 * : The name(s) of the keys within the value to locate the value to patch.
	 *
	 * [<value>]
	 * : The new value. If omitted, the value is read from STDIN.
	 *
	 * [--format=<format>]
	 * : The serialization format for the value.
	 * ---
	 * default: plaintext
	 * options:
	 *   - plaintext
	 *   - json
	 * ---
	 *
	 * [--expiration=<expiration>]
	 * : Time until expiration, in seconds.
	 *
	 * [--network]
	 * : Get the value of a network|site transient. On single site, this is
	 * a specially-named cache key. On multisite, this is a global cache
	 * (instead of local to the site).
	 */
	public function patch( $args, $assoc_args ) {
		list( $action, $key ) = $args;
		$expiration           = (int) Utils\get_flag_value( $assoc_args, 'expiration', 0 );

		$read_func  = Utils\get_flag_value( $assoc_args, 'network' ) ? 'get_site_transient' : 'get_transient';
		$write_func = Utils\get_flag_value( $assoc_args, 'network' ) ? 'set_site_transient' : 'set_transient';

		$key_path = array_map(
			function ( $key ) {
				if ( is_numeric( $key ) && ( (string) intval( $key ) === $key ) ) {
					return (int) $key;
				}

				return $key;
			},
			array_slice( $args, 2 )
		);

		if ( 'delete' === $action ) {
			$patch_value = null;
		} else {
			$stdin_value = Utils\has_stdin()
				? trim( WP_CLI::get_value_from_arg_or_stdin( $args, -1 ) )
				: null;

			if ( ! empty( $stdin_value ) ) {
				$patch_value = WP_CLI::read_value( $stdin_value, $assoc_args );
			} elseif ( count( $key_path ) > 1 ) {
				$patch_value = WP_CLI::read_value( array_pop( $key_path ), $assoc_args );
			} else {
				$patch_value = null;
			}

			if ( null === $patch_value ) {
				WP_CLI::error( 'Please provide value to update.' );
			}
		}

		/* Need to make a copy of $current_value here as it is modified by reference */
		$old_value     = $read_func( $key );
		$current_value = $old_value;
		if ( is_object( $old_value ) ) {
			$current_value = clone $old_value;
		}

		$traverser = new RecursiveDataStructureTraverser( $current_value );

		try {
			$traverser->$action( $key_path, $patch_value );
		} catch ( \Exception $e ) {
			WP_CLI::error( $e->getMessage() );
		}

		$patched_value = $traverser->value();

		if ( $patched_value === $old_value ) {
			WP_CLI::success( "Value passed for transient '$key' is unchanged." );
		} else {
			$success = $write_func( $key, $patched_value, $expiration );
			if ( $success ) {
				WP_CLI::success( "Updated transient '$key'." );
			} else {
				WP_CLI::error( "Could not update transient '$key'." );
			}
		}
	}

	/**
	 * Retrieves the expiration time.
	 *
	 * @param string $name              Transient name.
	 * @param bool   $is_site_transient Optional. Whether this is a site transient. Default false.
	 * @param bool   $human_readable    Optional. Whether to return the difference between now and the
	 *                                  expiration time in a human-readable format. Default false.
	 * @return string Expiration time string.
	 */
	private function get_transient_expiration( $name, $is_site_transient = false, $human_readable = false ) {
		if ( $is_site_transient ) {
			if ( is_multisite() ) {
				$expiration = (int) get_site_option( '_site_transient_timeout_' . $name );
			} else {
				$expiration = (int) get_option( '_site_transient_timeout_' . $name );
			}
		} else {
			$expiration = (int) get_option( '_transient_timeout_' . $name );
		}

		if ( 0 === $expiration ) {
			return $human_readable ? 'never expires' : 'false';
		}

		if ( ! $human_readable ) {
			return $expiration;
		}

		$now = time();

		if ( $now > $expiration ) {
			return 'expired';
		}

		return human_time_diff( $now, $expiration );
	}

	/**
	 * Deletes all expired transients.
	 *
	 * Only deletes the expired transients from the database.
	 *
	 * @param bool $network Whether to delete transients or network|site transients.
	 */
	private function delete_expired( $network ) {
		global $wpdb;

		$count = 0;

		if ( ! $network ) {
			$count += $wpdb->query(
				$wpdb->prepare(
					"DELETE a, b FROM {$wpdb->options} a, {$wpdb->options} b
						WHERE a.option_name LIKE %s
						AND a.option_name NOT LIKE %s
						AND b.option_name = CONCAT( '_transient_timeout_', SUBSTRING( a.option_name, 12 ) )
						AND b.option_value < %d",
					Utils\esc_like( '_transient_' ) . '%',
					Utils\esc_like( '_transient_timeout_' ) . '%',
					time()
				)
			);
		} elseif ( ! is_multisite() ) {
				// Non-Multisite stores site transients in the options table.
				$count += $wpdb->query(
					$wpdb->prepare(
						"DELETE a, b FROM {$wpdb->options} a, {$wpdb->options} b
							WHERE a.option_name LIKE %s
							AND a.option_name NOT LIKE %s
							AND b.option_name = CONCAT( '_site_transient_timeout_', SUBSTRING( a.option_name, 17 ) )
							AND b.option_value < %d",
						Utils\esc_like( '_site_transient_' ) . '%',
						Utils\esc_like( '_site_transient_timeout_' ) . '%',
						time()
					)
				);
		} else {
			// Multisite stores site transients in the sitemeta table.
			$count += $wpdb->query(
				$wpdb->prepare(
					"DELETE a, b FROM {$wpdb->sitemeta} a, {$wpdb->sitemeta} b
							WHERE a.meta_key LIKE %s
							AND a.meta_key NOT LIKE %s
							AND b.meta_key = CONCAT( '_site_transient_timeout_', SUBSTRING( a.meta_key, 17 ) )
							AND b.meta_value < %d",
					Utils\esc_like( '_site_transient_' ) . '%',
					Utils\esc_like( '_site_transient_timeout_' ) . '%',
					time()
				)
			);
		}

		// The above queries delete the transient and the transient timeout
		// thus each transient is counted twice.
		$count = $count / 2;

		if ( $count > 0 ) {
			WP_CLI::success(
				sprintf(
					'%d expired %s deleted from the database.',
					$count,
					Utils\pluralize( 'transient', $count )
				)
			);
		} else {
			WP_CLI::success( 'No expired transients found.' );
		}

		if ( wp_using_ext_object_cache() ) {
			WP_CLI::warning( 'Transients are stored in an external object cache, and this command only deletes those stored in the database. You must flush the cache to delete all transients.' );
		}
	}

	/**
	 * Deletes all transients.
	 *
	 * Only deletes the transients from the database.
	 *
	 * @param bool $network Whether to delete transients or network|site transients.
	 */
	private function delete_all( $network ) {
		global $wpdb;

		// To ensure proper count values we first delete all transients with a timeout
		// and then the remaining transients without a timeout.
		$count = 0;

		if ( ! $network ) {
			$deleted = $wpdb->query(
				$wpdb->prepare(
					"DELETE a, b FROM {$wpdb->options} a, {$wpdb->options} b
						WHERE a.option_name LIKE %s
						AND a.option_name NOT LIKE %s
						AND b.option_name = CONCAT( '_transient_timeout_', SUBSTRING( a.option_name, 12 ) )",
					Utils\esc_like( '_transient_' ) . '%',
					Utils\esc_like( '_transient_timeout_' ) . '%'
				)
			);

			$count += $deleted / 2; // Ignore affected rows for timeouts.

			$count += $wpdb->query(
				$wpdb->prepare(
					"DELETE FROM $wpdb->options WHERE option_name LIKE %s",
					Utils\esc_like( '_transient_' ) . '%'
				)
			);
		} elseif ( ! is_multisite() ) {
				// Non-Multisite stores site transients in the options table.
				$deleted = $wpdb->query(
					$wpdb->prepare(
						"DELETE a, b FROM {$wpdb->options} a, {$wpdb->options} b
							WHERE a.option_name LIKE %s
							AND a.option_name NOT LIKE %s
							AND b.option_name = CONCAT( '_site_transient_timeout_', SUBSTRING( a.option_name, 17 ) )",
						Utils\esc_like( '_site_transient_' ) . '%',
						Utils\esc_like( '_site_transient_timeout_' ) . '%'
					)
				);

				$count += $deleted / 2; // Ignore affected rows for timeouts.

				$count += $wpdb->query(
					$wpdb->prepare(
						"DELETE FROM $wpdb->options WHERE option_name LIKE %s",
						Utils\esc_like( '_site_transient_' ) . '%'
					)
				);
		} else {
			// Multisite stores site transients in the sitemeta table.
			$deleted = $wpdb->query(
				$wpdb->prepare(
					"DELETE a, b FROM {$wpdb->sitemeta} a, {$wpdb->sitemeta} b
							WHERE a.meta_key LIKE %s
							AND a.meta_key NOT LIKE %s
							AND b.meta_key = CONCAT( '_site_transient_timeout_', SUBSTRING( a.meta_key, 17 ) )",
					Utils\esc_like( '_site_transient_' ) . '%',
					Utils\esc_like( '_site_transient_timeout_' ) . '%'
				)
			);

			$count += $deleted / 2; // Ignore affected rows for timeouts.

			$count += $wpdb->query(
				$wpdb->prepare(
					"DELETE FROM $wpdb->sitemeta WHERE meta_key LIKE %s",
					Utils\esc_like( '_site_transient_' ) . '%'
				)
			);
		}

		if ( $count > 0 ) {
			WP_CLI::success(
				sprintf(
					'%d %s deleted from the database.',
					$count,
					Utils\pluralize( 'transient', $count )
				)
			);
		} else {
			WP_CLI::success( 'No transients found.' );
		}

		if ( wp_using_ext_object_cache() ) {
			WP_CLI::warning( 'Transients are stored in an external object cache, and this command only deletes those stored in the database. You must flush the cache to delete all transients.' );
		}
	}
}
