<?php

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

/**
 * Retrieves and sets site options, including plugin and WordPress settings.
 *
 * See the [Plugin Settings API](https://developer.wordpress.org/plugins/settings/settings-api/) and the [Theme Options](https://developer.wordpress.org/themes/customize-api/) for more information on adding customized options.
 *
 * ## EXAMPLES
 *
 *     # Get site URL.
 *     $ wp option get siteurl
 *     http://example.com
 *
 *     # Add option.
 *     $ wp option add my_option foobar
 *     Success: Added 'my_option' option.
 *
 *     # Update option.
 *     $ wp option update my_option '{"foo": "bar"}' --format=json
 *     Success: Updated 'my_option' option.
 *
 *     # Delete option.
 *     $ wp option delete my_option
 *     Success: Deleted 'my_option' option.
 *
 * @package wp-cli
 */
class Option_Command extends WP_CLI_Command {

	/**
	 * Gets the value for an option.
	 *
	 * ## OPTIONS
	 *
	 * <key>
	 * : Key for the option.
	 *
	 * [--format=<format>]
	 * : Get value in a particular format.
	 * ---
	 * default: var_export
	 * options:
	 *   - var_export
	 *   - json
	 *   - yaml
	 * ---
	 *
	 * ## EXAMPLES
	 *
	 *     # Get option.
	 *     $ wp option get home
	 *     http://example.com
	 *
	 *     # Get blog description.
	 *     $ wp option get blogdescription
	 *     A random blog description
	 *
	 *     # Get blog name
	 *     $ wp option get blogname
	 *     A random blog name
	 *
	 *     # Get admin email.
	 *     $ wp option get admin_email
	 *     someone@example.com
	 *
	 *     # Get option in JSON format.
	 *     $ wp option get active_plugins --format=json
	 *     {"0":"dynamically-dynamic-sidebar\/dynamically-dynamic-sidebar.php","1":"monster-widget\/monster-widget.php","2":"show-current-template\/show-current-template.php","3":"theme-check\/theme-check.php","5":"wordpress-importer\/wordpress-importer.php"}
	 */
	public function get( $args, $assoc_args ) {
		list( $key ) = $args;

		$value = get_option( $key );

		if ( false === $value ) {
			WP_CLI::error( "Could not get '{$key}' option. Does it exist?" );
		}

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

	/**
	 * Adds a new option value.
	 *
	 * Errors if the option already exists.
	 *
	 * ## OPTIONS
	 *
	 * <key>
	 * : The name of the option to add.
	 *
	 * [<value>]
	 * : The value of the option to add. If omitted, the value is read from STDIN.
	 *
	 * [--format=<format>]
	 * : The serialization format for the value.
	 * ---
	 * default: plaintext
	 * options:
	 *   - plaintext
	 *   - json
	 * ---
	 *
	 * [--autoload=<autoload>]
	 * : Should this option be automatically loaded.
	 * ---
	 * options:
	 *   - 'on'
	 *   - 'off'
	 *   - 'yes'
	 *   - 'no'
	 * ---
	 *
	 * ## EXAMPLES
	 *
	 *     # Create an option by reading a JSON file.
	 *     $ wp option add my_option --format=json < config.json
	 *     Success: Added 'my_option' option.
	 */
	public function add( $args, $assoc_args ) {
		$key = $args[0];

		$value = WP_CLI::get_value_from_arg_or_stdin( $args, 1 );
		$value = WP_CLI::read_value( $value, $assoc_args );

		if ( in_array( Utils\get_flag_value( $assoc_args, 'autoload' ), [ 'no', 'off' ], true ) ) {
			$autoload = 'no';
		} else {
			$autoload = 'yes';
		}

		if ( ! add_option( $key, $value, '', $autoload ) ) {
			WP_CLI::error( "Could not add option '{$key}'. Does it already exist?" );
		} else {
			WP_CLI::success( "Added '{$key}' option." );
		}
	}

	/**
	 * Lists options and their values.
	 *
	 * ## OPTIONS
	 *
	 * [--search=<pattern>]
	 * : Use wildcards ( * and ? ) to match option name.
	 *
	 * [--exclude=<pattern>]
	 * : Pattern to exclude. Use wildcards ( * and ? ) to match option name.
	 *
	 * [--autoload=<value>]
	 * : Match only autoload options when value is on, and only not-autoload option when off.
	 *
	 * [--transients]
	 * : List only transients. Use `--no-transients` to ignore all transients.
	 *
	 * [--unserialize]
	 * : Unserialize option values in output.
	 *
	 * [--field=<field>]
	 * : Prints the value of a single field.
	 *
	 * [--fields=<fields>]
	 * : Limit the output to specific object fields.
	 *
	 * [--format=<format>]
	 * : The serialization format for the value. total_bytes displays the total size of matching options in bytes.
	 * ---
	 * default: table
	 * options:
	 *   - table
	 *   - json
	 *   - csv
	 *   - count
	 *   - yaml
	 *   - total_bytes
	 * ---
	 *
	 * [--orderby=<fields>]
	 * : Set orderby which field.
	 * ---
	 * default: option_id
	 * options:
	 *  - option_id
	 *  - option_name
	 *  - option_value
	 * ---
	 *
	 * [--order=<order>]
	 * : Set ascending or descending order.
	 * ---
	 * default: asc
	 * options:
	 *  - asc
	 *  - desc
	 * ---
	 *
	 * ## AVAILABLE FIELDS
	 *
	 * This field will be displayed by default for each matching option:
	 *
	 * * option_name
	 * * option_value
	 *
	 * These fields are optionally available:
	 *
	 * * autoload
	 * * size_bytes
	 *
	 * ## EXAMPLES
	 *
	 *     # Get the total size of all autoload options.
	 *     $ wp option list --autoload=on --format=total_bytes
	 *     33198
	 *
	 *     # Find biggest transients.
	 *     $ wp option list --search="*_transient_*" --fields=option_name,size_bytes | sort -n -k 2 | tail
	 *     option_name size_bytes
	 *     _site_transient_timeout_theme_roots 10
	 *     _site_transient_theme_roots 76
	 *     _site_transient_update_themes   181
	 *     _site_transient_update_core 808
	 *     _site_transient_update_plugins  6645
	 *
	 *     # List all options beginning with "i2f_".
	 *     $ wp option list --search="i2f_*"
	 *     +-------------+--------------+
	 *     | option_name | option_value |
	 *     +-------------+--------------+
	 *     | i2f_version | 0.1.0        |
	 *     +-------------+--------------+
	 *
	 *     # Delete all options beginning with "theme_mods_".
	 *     $ wp option list --search="theme_mods_*" --field=option_name | xargs -I % wp option delete %
	 *     Success: Deleted 'theme_mods_twentysixteen' option.
	 *     Success: Deleted 'theme_mods_twentyfifteen' option.
	 *     Success: Deleted 'theme_mods_twentyfourteen' option.
	 *
	 * @subcommand list
	 */
	public function list_( $args, $assoc_args ) {

		global $wpdb;
		$pattern        = '%';
		$exclude        = '';
		$fields         = array( 'option_name', 'option_value' );
		$size_query     = ',LENGTH(option_value) AS `size_bytes`';
		$autoload_query = '';

		if ( isset( $assoc_args['search'] ) ) {
			$pattern = self::esc_like( $assoc_args['search'] );
			// substitute wildcards
			$pattern = str_replace( '*', '%', $pattern );
			$pattern = str_replace( '?', '_', $pattern );
		}

		if ( isset( $assoc_args['exclude'] ) ) {
			$exclude = self::esc_like( $assoc_args['exclude'] );
			$exclude = str_replace( '*', '%', $exclude );
			$exclude = str_replace( '?', '_', $exclude );
		}

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

		if ( Utils\get_flag_value( $assoc_args, 'format' ) === 'total_bytes' ) {
			$fields     = array( 'size_bytes' );
			$size_query = ',SUM(LENGTH(option_value)) AS `size_bytes`';
		}

		if ( isset( $assoc_args['autoload'] ) ) {
			$autoload = $assoc_args['autoload'];
			if ( 'on' === $autoload || 'yes' === $autoload ) {
				$autoload_query = " AND (autoload='on') OR (autoload='yes')";
			} elseif ( 'off' === $autoload || 'no' === $autoload ) {
				$autoload_query = " AND (autoload='off') OR (autoload='no')";
			} else {
				WP_CLI::error( "Value of '--autoload' should be 'on', 'off', 'yes', or 'no'." );
			}
		}

		// By default we don't want to display transients.
		$show_transients = Utils\get_flag_value( $assoc_args, 'transients', false );

		if ( $show_transients ) {
			$transients_query = " AND option_name LIKE '\_transient\_%'
			OR option_name LIKE '\_site\_transient\_%'";
		} else {
			$transients_query = " AND option_name NOT LIKE '\_transient\_%'
			AND option_name NOT LIKE '\_site\_transient\_%'";
		}

		$where = '';
		if ( $pattern ) {
			$where .= $wpdb->prepare( 'WHERE `option_name` LIKE %s', $pattern );
		}

		if ( $exclude ) {
			$where .= $wpdb->prepare( ' AND `option_name` NOT LIKE %s', $exclude );
		}
		$where .= $autoload_query . $transients_query;

		// phpcs:disable WordPress.DB.PreparedSQL -- Hardcoded query parts without user input.
		$results = $wpdb->get_results(
			'SELECT `option_name`,`option_value`,`autoload`' . $size_query
			. " FROM `$wpdb->options` {$where}"
		);
		// phpcs:enable

		$orderby = Utils\get_flag_value( $assoc_args, 'orderby' );
		$order   = Utils\get_flag_value( $assoc_args, 'order' );

		// Sort result.
		if ( 'option_id' !== $orderby ) {
			usort(
				$results,
				function ( $a, $b ) use ( $orderby, $order ) {
					// Sort array.
					return 'asc' === $order
						? $a->$orderby > $b->$orderby
						: $a->$orderby < $b->$orderby;
				}
			);
		} elseif ( 'option_id' === $orderby && 'desc' === $order ) { // Sort by default descending.
			krsort( $results );
		}

		if ( true === Utils\get_flag_value( $assoc_args, 'unserialize', null ) ) {
			foreach ( $results as $k => &$v ) {
				if ( ! empty( $v->option_value ) ) {
					$v->option_value = maybe_unserialize( $v->option_value );
				}
			}
		}

		if ( Utils\get_flag_value( $assoc_args, 'format' ) === 'total_bytes' ) {
			WP_CLI::line( $results[0]->size_bytes );
		} else {
			$formatter = new Formatter(
				$assoc_args,
				$fields
			);
			$formatter->display_items( $results );
		}
	}

	/**
	 * Updates an option value.
	 *
	 * ## OPTIONS
	 *
	 * <key>
	 * : The name of the option to update.
	 *
	 * [<value>]
	 * : The new value. If omitted, the value is read from STDIN.
	 *
	 * [--autoload=<autoload>]
	 * : Requires WP 4.2. Should this option be automatically loaded.
	 * ---
	 * options:
	 *   - 'on'
	 *   - 'off'
	 *   - 'yes'
	 *   - 'no'
	 * ---
	 *
	 * [--format=<format>]
	 * : The serialization format for the value.
	 * ---
	 * default: plaintext
	 * options:
	 *   - plaintext
	 *   - json
	 * ---
	 *
	 * ## EXAMPLES
	 *
	 *     # Update an option by reading from a file.
	 *     $ wp option update my_option < value.txt
	 *     Success: Updated 'my_option' option.
	 *
	 *     # Update one option on multiple sites using xargs.
	 *     $ wp site list --field=url | xargs -n1 -I {} sh -c 'wp --url={} option update my_option my_value'
	 *     Success: Updated 'my_option' option.
	 *     Success: Updated 'my_option' option.
	 *
	 *     # Update site blog name.
	 *     $ wp option update blogname "Random blog name"
	 *     Success: Updated 'blogname' option.
	 *
	 *     # Update site blog description.
	 *     $ wp option update blogdescription "Some random blog description"
	 *     Success: Updated 'blogdescription' option.
	 *
	 *     # Update admin email address.
	 *     $ wp option update admin_email someone@example.com
	 *     Success: Updated 'admin_email' option.
	 *
	 *     # Set the default role.
	 *     $ wp option update default_role author
	 *     Success: Updated 'default_role' option.
	 *
	 *     # Set the timezone string.
	 *     $ wp option update timezone_string "America/New_York"
	 *     Success: Updated 'timezone_string' option.
	 *
	 * @alias set
	 */
	public function update( $args, $assoc_args ) {
		$key = $args[0];

		$value = WP_CLI::get_value_from_arg_or_stdin( $args, 1 );
		$value = WP_CLI::read_value( $value, $assoc_args );

		$autoload = Utils\get_flag_value( $assoc_args, 'autoload' );
		if ( ! in_array( $autoload, [ 'on', 'off', 'yes', 'no' ], true ) ) {
			$autoload = null;
		}

		$value = sanitize_option( $key, $value );
		// Sanitization WordPress normally performs when getting an option
		if ( in_array( $key, [ 'siteurl', 'home', 'category_base', 'tag_base' ], true ) ) {
			$value = untrailingslashit( $value );
		}
		$old_value = sanitize_option( $key, get_option( $key ) );

		if ( $value === $old_value && null === $autoload ) {
			WP_CLI::success( "Value passed for '{$key}' option is unchanged." );
		} elseif ( update_option( $key, $value, $autoload ) ) {
				WP_CLI::success( "Updated '{$key}' option." );
		} else {
			WP_CLI::error( "Could not update option '{$key}'." );
		}
	}

	/**
	 * Gets the 'autoload' value for an option.
	 *
	 * ## OPTIONS
	 *
	 * <key>
	 * : The name of the option to get 'autoload' of.
	 *
	 * ## EXAMPLES
	 *
	 *     # Get the 'autoload' value for an option.
	 *     $ wp option get-autoload blogname
	 *     yes
	 *
	 * @subcommand get-autoload
	 */
	public function get_autoload( $args ) {
		global $wpdb;

		list( $option ) = $args;

		$existing = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT autoload FROM $wpdb->options WHERE option_name=%s",
				$option
			)
		);
		if ( ! $existing ) {
			WP_CLI::error( "Could not get '{$option}' option. Does it exist?" );

		}
		WP_CLI::log( $existing->autoload );
	}

	/**
	 * Sets the 'autoload' value for an option.
	 *
	 * ## OPTIONS
	 *
	 * <key>
	 * : The name of the option to set 'autoload' for.
	 *
	 * <autoload>
	 * : Should this option be automatically loaded.
	 * ---
	 * options:
	 *   - 'on'
	 *   - 'off'
	 *   - 'yes'
	 *   - 'no'
	 * ---
	 *
	 * ## EXAMPLES
	 *
	 *     # Set the 'autoload' value for an option.
	 *     $ wp option set-autoload abc_options no
	 *     Success: Updated autoload value for 'abc_options' option.
	 *
	 * @subcommand set-autoload
	 */
	public function set_autoload( $args ) {
		global $wpdb;

		list( $option, $autoload ) = $args;

		$previous = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT autoload, option_value FROM $wpdb->options WHERE option_name=%s",
				$option
			)
		);
		if ( ! $previous ) {
			WP_CLI::error( "Could not get '{$option}' option. Does it exist?" );

		}

		if ( $previous->autoload === $autoload ) {
			WP_CLI::success( "Autoload value passed for '{$option}' option is unchanged." );
			return;
		}

		$wpdb->update(
			$wpdb->options,
			array( 'autoload' => $autoload ),
			array( 'option_name' => $option )
		);

		// Recreate cache refreshing from update_option().
		$notoptions = wp_cache_get( 'notoptions', 'options' );

		if ( is_array( $notoptions ) && isset( $notoptions[ $option ] ) ) {
			unset( $notoptions[ $option ] );
			wp_cache_set( 'notoptions', $notoptions, 'options' );
		}

		if ( ! defined( 'WP_INSTALLING' ) ) {
			$alloptions = wp_load_alloptions( true );
			if ( isset( $alloptions[ $option ] ) ) {
				$alloptions[ $option ] = $previous->option_value;
				wp_cache_set( 'alloptions', $alloptions, 'options' );
			} else {
				wp_cache_set( $option, $previous->option_value, 'options' );
			}
		}

		WP_CLI::success( "Updated autoload value for '{$option}' option." );
	}

	/**
	 * Deletes an option.
	 *
	 * ## OPTIONS
	 *
	 * <key>...
	 * : Key for the option.
	 *
	 * ## EXAMPLES
	 *
	 *     # Delete an option.
	 *     $ wp option delete my_option
	 *     Success: Deleted 'my_option' option.
	 *
	 *     # Delete multiple options.
	 *     $ wp option delete option_one option_two option_three
	 *     Success: Deleted 'option_one' option.
	 *     Success: Deleted 'option_two' option.
	 *     Warning: Could not delete 'option_three' option. Does it exist?
	 */
	public function delete( $args ) {
		foreach ( $args as $arg ) {
			if ( ! delete_option( $arg ) ) {
				WP_CLI::warning( "Could not delete '{$arg}' option. Does it exist?" );
			} else {
				WP_CLI::success( "Deleted '{$arg}' option." );
			}
		}
	}

	/**
	 * Gets a nested value from an option.
	 *
	 * ## OPTIONS
	 *
	 * <key>
	 * : The option name.
	 *
	 * <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
	 * ---
	 */
	public function pluck( $args, $assoc_args ) {
		list( $key ) = $args;

		$value = get_option( $key );

		if ( false === $value ) {
			WP_CLI::halt( 1 );
		}

		$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 $exception ) {
			die( 1 );
		}

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

	/**
	 * Updates a nested value in an option.
	 *
	 * ## OPTIONS
	 *
	 * <action>
	 * : Patch action to perform.
	 * ---
	 * options:
	 *   - insert
	 *   - update
	 *   - delete
	 * ---
	 *
	 * <key>
	 * : The option name.
	 *
	 * <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
	 * ---
	 *
	 * ## EXAMPLES
	 *
	 *     # Add 'bar' to the 'foo' key on an option with name 'option_name'
	 *     $ wp option patch insert option_name foo bar
	 *     Success: Updated 'option_name' option.
	 *
	 *     # Update the value of 'foo' key to 'new' on an option with name 'option_name'
	 *     $ wp option patch update option_name foo new
	 *     Success: Updated 'option_name' option.
	 *
	 *     # Set nested value of 'bar' key to value we have in the patch file on an option with name 'option_name'.
	 *     $ wp option patch update option_name foo bar < patch
	 *     Success: Updated 'option_name' option.
	 *
	 *     # Update the value for the key 'not-a-key' which is not exist on an option with name 'option_name'.
	 *     $ wp option patch update option_name foo not-a-key new-value
	 *     Error: No data exists for key "not-a-key"
	 *
	 *     # Update the value for the key 'foo' without passing value on an option with name 'option_name'.
	 *     $ wp option patch update option_name foo
	 *     Error: Please provide value to update.
	 *
	 *     # Delete the nested key 'bar' under 'foo' key on an option with name 'option_name'.
	 *     $ wp option patch delete option_name foo bar
	 *     Success: Updated 'option_name' option.
	 */
	public function patch( $args, $assoc_args ) {
		list( $action, $key ) = $args;
		$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     = sanitize_option( $key, get_option( $key ) );
		$current_value = $old_value;
		if ( is_object( $current_value ) ) {
			$old_value = clone $current_value;
		}

		$traverser = new RecursiveDataStructureTraverser( $current_value );

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

		$patched_value = sanitize_option( $key, $traverser->value() );

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

	private static function esc_like( $old ) {
		global $wpdb;

		// Remove notices in 4.0 and support backwards compatibility
		if ( method_exists( $wpdb, 'esc_like' ) ) {
			// 4.0
			$old = $wpdb->esc_like( $old );
		} else {
			// phpcs:ignore WordPress.WP.DeprecatedFunctions.like_escapeFound -- called in WordPress 3.9 or less.
			$old = like_escape( esc_sql( $old ) );
		}

		return $old;
	}
}
