<?php
/**
 * @package    akeebabackupwp
 * @copyright  Copyright (c)2014-2019 Nicholas K. Dionysopoulos / Akeeba Ltd
 * @license    GNU GPL version 3 or later
 */

namespace Akeeba\WPCLI\Command;

use Akeeba\Engine\Factory;
use Akeeba\Engine\Platform;
use Akeeba\Engine\Util\ParseIni;
use Awf\Mvc\Model;
use Exception;
use Solo\Application;
use WP_CLI;
use WP_CLI\Utils as CliUtils;

/**
 * View and change the configuration options of your Akeeba Backup profiles.
 *
 * @package     Akeeba\WPCLI\Command
 *
 * @since       3.0.0
 */
class Option
{
	/**
	 * Lists the configuration options for an Akeeba Backup profile, including their titles
	 *
	 * ## OPTIONS
	 *
	 * [--profile=<profile>]
	 * : The backup profile to use. Default: 1.
	 *
	 * [--filter=<filter>]
	 * : Only return records whose keys begin with the given filter
	 *
	 * [--sort-by=<column>]
	 * : Sort the output by the given column.
	 * ---
	 * default: none
	 * options:
	 *   - none
	 *   - key
	 *   - value
	 *   - type
	 *   - default
	 *   - title
	 *   - description
	 *   - section
	 * ---
	 *
	 * [--sort-order=<sortOrder>]
	 * : Sort order
	 * ---
	 * default: asc
	 * options:
	 *   - asc
	 *   - desc
	 * ---
	 *
	 * [--format=<format>]
	 * : The format for the returned list
	 * ---
	 * default: table
	 * options:
	 *   - table
	 *   - json
	 *   - csv
	 *   - yaml
	 *   - count
	 * ---
	 *
	 * ## EXAMPLES
	 *
	 *     wp akeeba option list
	 *
	 *     wp akeeba option list --profile=2 --format=json
	 *
	 *     wp akeeba option list --profile=2 --filter="core." --sort-by=key --sort-order=desc
	 *
	 * @when       after_wp_load
	 * @subcommand list
	 *
	 * @param   array $args       Positional arguments (literal arguments)
	 * @param   array $assoc_args Associative arguments (--flag, --no-flag, --key=value)
	 *
	 * @return  void
	 *
	 * @throws  WP_CLI\ExitException
	 *
	 * @since       3.0.0
	 */
	public function _list($args, $assoc_args)
	{
		/** @var  Application $akeebaBackupApplication */
		global $akeebaBackupApplication;
		$container = $akeebaBackupApplication->getContainer();

		$format    = isset($assoc_args['format']) ? $assoc_args['format'] : 'table';
		$profileID = isset($assoc_args['profile']) ? (int) $assoc_args['profile'] : 1;
		$profileID = max(1, $profileID);

		/** @var \Solo\Model\Profiles $model */
		$model = Model::getTmpInstance($container->application_name, 'Profiles', $container);

		try
		{
			$model->findOrFail($profileID);
		}
		catch (Exception $e)
		{
			WP_CLI::error("Could not find profile #$profileID.");
		}

		unset($model);

		// Get the profile's configuration
		Platform::getInstance()->load_configuration($profileID);
		$config  = Factory::getConfiguration();
		$rawJson = $config->exportAsJSON();

		unset($config);

		// Get the key information from the GUI data
		$info = $this->parseJsonGuiData();

		// Convert the INI data we got into an array we can print
		$rawValues = json_decode($rawJson, true);

		unset($rawJson);

		$output = [];

		$rawValues = $this->flattenOptions($rawValues);

		foreach ($rawValues as $key => $v)
		{
			$output[$key] = array_merge([
				'key'          => $key,
				'value'        => $v,
				'title'        => '',
				'description'  => '',
				'type'         => '',
				'default'      => '',
				'section'      => '',
				'options'      => [],
				'optionTitles' => [],
				'limits'       => [],
			], $this->getOptionInfo($key, $info));
		}

		// Filter the returned options
		$filter = isset($assoc_args['filter']) ? $assoc_args['filter'] : '';

		$output = array_filter($output, function ($item) use ($filter) {
			if (!empty($filter) && strpos($item['key'], $filter) !== 0)
			{
				return false;
			}

			return $item['type'] != 'hidden';
		});

		// Sort the results
		$sort  = isset($assoc_args['sort-by']) ? $assoc_args['sort-by'] : 'none';
		$order = isset($assoc_args['sort-order']) ? $assoc_args['sort-order'] : 'asc';

		if ($sort != 'none')
		{
			usort($output, function ($a, $b) use ($sort, $order) {
				if ($a[$sort] == $b[$sort])
				{
					return 0;
				}

				$signChange = ($order == 'asc') ? 1 : -1;
				$isGreater  = $a[$sort] > $b[$sort] ? 1 : -1;

				return $signChange * $isGreater;
			});
		}

		// Output the list
		if (empty($output))
		{
			WP_CLI::error("No options found matching your criteria.");
		}

		$keys     = array_keys($output);
		$firstKey = array_shift($keys);
		CliUtils\format_items($format, $output, array_keys($output[$firstKey]));
	}

	/**
	 * Gets the value of a configuration option for an Akeeba Backup profile
	 *
	 * ## OPTIONS
	 *
	 * <key>
	 * : The option key to retrieve
	 *
	 * [--profile=<profile>]
	 * : The backup profile to use. Default: 1.
	 *
	 * [--format=<format>]
	 * : The format for the returned value
	 * ---
	 * default: text
	 * options:
	 *   - text
	 *   - json
	 *   - print_r
	 *   - var_dump
	 *   - var_export
	 * ---
	 *
	 * ## EXAMPLES
	 *
	 *     wp akeeba option get akeeba.basic.archive_name
	 *
	 *     wp akeeba option get akeeba.basic.archive_name --profile=2
	 *
	 *     wp akeeba option get akeeba.basic.archive_name --profile=2 --format=var_export
	 *
	 * @when       after_wp_load
	 *
	 * @param   array $args       Positional arguments (literal arguments)
	 * @param   array $assoc_args Associative arguments (--flag, --no-flag, --key=value)
	 *
	 * @return  void
	 *
	 * @throws  WP_CLI\ExitException
	 *
	 * @since       3.0.0
	 */
	public function get($args, $assoc_args)
	{
		$key = isset($args[0]) ? $args[0] : null;

		if (empty($key))
		{
			WP_CLI::error("You must specify the option key to retrieve.");
		}

		/** @var  Application $akeebaBackupApplication */
		global $akeebaBackupApplication;
		$container = $akeebaBackupApplication->getContainer();

		$profileID = isset($assoc_args['profile']) ? (int) $assoc_args['profile'] : 1;
		$profileID = max(1, $profileID);

		/** @var \Solo\Model\Profiles $model */
		$model = Model::getTmpInstance($container->application_name, 'Profiles', $container);

		try
		{
			$model->findOrFail($profileID);
		}
		catch (Exception $e)
		{
			WP_CLI::error("Could not find profile #$profileID.");
		}

		unset($model);

		// Get the profile's configuration
		Platform::getInstance()->load_configuration($profileID);
		$config = Factory::getConfiguration();

		$value = $config->get($key, '', false);

		switch ($assoc_args['format'])
		{
			case 'text':
			default:
				echo $value;
				break;

			case 'json':
				echo json_encode($value);
				break;

			case 'print_r':
				print_r($value);
				break;

			case 'var_dump':
				var_dump($value);
				break;

			case 'var_export':
				var_export($value);
				break;

		}
	}

	/**
	 * Sets the value of a configuration option for an Akeeba Backup profile
	 *
	 * ## OPTIONS
	 *
	 * <key>
	 * : The option key to set
	 *
	 * <value>
	 * : The value to set
	 *
	 * [--profile=<profile>]
	 * : The backup profile to use. Default: 1.
	 *
	 * [--force]
	 * : Allow setting of protected options.
	 *
	 * ## EXAMPLES
	 *
	 *     wp akeeba option set akeeba.basic.archive_name "site-[HOST]-[DATE]-[TIME]"
	 *
	 *     wp akeeba option set akeeba.basic.archive_name "site-[HOST]-[DATE]-[TIME]" --profile=2
	 *
	 * @when       after_wp_load
	 *
	 * @param   array $args       Positional arguments (literal arguments)
	 * @param   array $assoc_args Associative arguments (--flag, --no-flag, --key=value)
	 *
	 * @return  void
	 *
	 * @throws  WP_CLI\ExitException
	 *
	 * @since       3.0.0
	 */
	public function set($args, $assoc_args)
	{
		$key   = isset($args[0]) ? $args[0] : null;
		$value = isset($args[1]) ? $args[1] : null;

		if (empty($key))
		{
			WP_CLI::error("You must specify the option key to set.");
		}

		if (is_null($value))
		{
			WP_CLI::error("You must specify the option value to set.");
		}

		/** @var  Application $akeebaBackupApplication */
		global $akeebaBackupApplication;
		$container = $akeebaBackupApplication->getContainer();

		$profileID = isset($assoc_args['profile']) ? (int) $assoc_args['profile'] : 1;
		$profileID = max(1, $profileID);

		/** @var \Solo\Model\Profiles $model */
		$model = Model::getTmpInstance($container->application_name, 'Profiles', $container);

		try
		{
			$model->findOrFail($profileID);
		}
		catch (Exception $e)
		{
			WP_CLI::error("Could not find profile #$profileID.");
		}

		unset($model);

		// Get the profile's configuration
		Platform::getInstance()->load_configuration($profileID);
		$config = Factory::getConfiguration();

		// Get the key information from the GUI data
		$info = $this->parseJsonGuiData();

		// Does the key exist?
		if (!array_key_exists($key, $info['options']))
		{
			WP_CLI::error("Invalid option key '$key'.");
		}

		// Validate / sanitize the value
		$optionInfo = $this->getOptionInfo($key, $info);

		switch ($optionInfo['type'])
		{
			case 'integer':
				$value = (int) $value;

				if (($value < $optionInfo['limits']['min']) || ($value > $optionInfo['limits']['max']))
				{
					WP_CLI::error("Invalid value '$value': out of bounds.");
				}
				break;

			case 'bool':
				if (is_numeric($value))
				{
					$value = (int) $value;
				}
				elseif (is_string($value))
				{
					$value = strtolower($value);
				}

				if (in_array($value, [false, 0, '0', 'false', 'no', 'off'], true))
				{
					$value = 0;
				}
				elseif (in_array($value, [true, 1, '1', 'true', 'yes', 'on'], true))
				{
					$value = 1;
				}
				else
				{
					WP_CLI::error("Invalid boolean value '$value': use one of 0, false, no, off, 1, true, yes or on.'");
				}

				break;

			case 'enum':
				if (!in_array($value, $optionInfo['options']))
				{
					$options = array_map(function ($v) {
						return "'$v'";
					}, $optionInfo['options']);
					$options = implode(', ', $options);

					WP_CLI::error("Invalid enumerated value '$value'. Must be one of $options.");
				}

				break;

			case 'hidden':
				WP_CLI::error("Setting hidden option '$key' is not allowed.");
				break;

			case 'string':
				break;

			default:
				WP_CLI::error("Unknown type {$optionInfo['type']} for option '$key'. Have you manually tampered with the option INI files?");
				break;
		}

		$protected = $config->getProtectedKeys();
		$force     = isset($assoc_args['force']) && $assoc_args['force'];

		if (in_array($key, $protected) && !$force)
		{
			WP_CLI::error("Cannot set protected option '$key'. Please use the --force option to override the protection.");
		}

		if (in_array($key, $protected) && $force)
		{
			$config->setKeyProtection($key, false);
		}

		$result = $config->set($key, $value, false);

		if ($result === false)
		{
			WP_CLI::error("Could not set option '$key'.");
		}

		Platform::getInstance()->save_configuration($profileID);

		WP_CLI::success("Successfully set option '$key' to '$value'");
	}

	/**
	 * Parse the JSON GUI definition returned by Akeeba Engine into something I can use to provide information about
	 * the options.
	 *
	 * @return  array
	 *
	 * @since       3.0.0
	 */
	private function parseJsonGuiData()
	{
		$jsonGUIData = Factory::getEngineParamsProvider()->getJsonGuiDefinition();
		$guiData     = json_decode($jsonGUIData, true);

		$ret = [
			'engines'    => [],
			'installers' => [],
			'options'    => [],
		];

		// Parse engines
		foreach ($guiData['engines'] as $engineType => $engineRecords)
		{
			if (!isset($ret['engines'][$engineType]))
			{
				$ret['engines'][$engineType] = [];
			}

			foreach ($engineRecords as $engineName => $record)
			{
				$ret['engines'][$engineType][$engineName] = [
					'title'       => $record['information']['title'],
					'description' => $record['information']['description'],
				];

				foreach ($record['parameters'] as $key => $optionRecord)
				{
					$ret['options'][$key] = array_merge($optionRecord, [
						'section' => $record['information']['title'],
					]);
				}
			}
		}

		// Parse installers
		foreach ($guiData['installers'] as $installerName => $installerInfo)
		{
			$ret['installers'][$installerName] = $installerInfo['name'];
		}

		// Parse GUI sections
		foreach ($guiData['gui'] as $section => $options)
		{
			foreach ($options as $key => $optionRecord)
			{
				$ret['options'][$key] = array_merge($optionRecord, [
					'section' => $section,
				]);
			}
		}

		return $ret;
	}

	/**
	 * Get the information for an option record.
	 *
	 * @param   string $key  The option key
	 * @param   array  $info The array returned by parseJsonGuiData
	 *
	 * @return  array
	 *
	 * @since       3.0.0
	 */
	private function getOptionInfo($key, &$info)
	{
		$ret = [];

		if (!isset($info['options'][$key]))
		{
			return $ret;
		}

		$keyInfo = $info['options'][$key];

		$ret = [
			'title'        => $keyInfo['title'],
			'description'  => $keyInfo['description'],
			'section'      => $keyInfo['section'],
			'type'         => $keyInfo['type'],
			'default'      => $keyInfo['default'],
			'options'      => [],
			'optionTitles' => [],
			'limits'       => [],
		];

		switch ($keyInfo['type'])
		{
			case 'integer':
				if (isset($keyInfo['shortcuts']))
				{
					$ret['options'] = explode('|', $keyInfo['shortcuts']);
				}

				$ret['limits'] = [
					'min' => $keyInfo['min'],
					'max' => $keyInfo['max'],
				];
				break;

			case 'bool':
				$ret['type']    = 'integer';
				$ret['options'] = [0, 1];
				$ret['limits']  = [
					'min' => 0,
					'max' => 1,
				];
				break;

			case 'engine':
				$ret['type']         = 'enum';
				$ret['type']         = 'string';
				$ret['options']      = array_keys($info['engines'][$keyInfo['subtype']]);
				$ret['optionTitles'] = [];

				foreach ($info['engines'][$keyInfo['subtype']] as $k => $details)
				{
					$ret['optionTitles'][$k] = $details['title'];
				}

				break;

			case 'installer':
				$ret['type']         = 'enum';
				$ret['type']         = 'string';
				$ret['options']      = array_keys($info['installers']);
				$ret['optionTitles'] = $info['installers'];

				break;

			case 'enum':
				$ret['type']         = 'string';
				$ret['options']      = explode('|', $keyInfo['enumvalues']);
				$ret['optionTitles'] = explode('|', $keyInfo['enumkeys']);

				break;

			case 'hidden':
			case 'button':
			case 'separator':
				$ret['type'] = 'hidden';
				break;

			case 'string':
			case 'browsedir':
			case 'password':
			default:
				$ret['type'] = 'string';
				break;
		}

		return $ret;
	}

	/**
	 * Flattens the option tree returned by exportToJson into an array with dotted notation for each option.
	 *
	 * @param   array   $rawOptions  The option tree
	 * @param   string  $prefix      Current prefix, used for recursion
	 *
	 * @return  array
	 */
	private function flattenOptions(array $rawOptions, $prefix = '')
	{
		$ret = [];

		foreach ($rawOptions as $k => $v)
		{
			if (is_array($v))
			{
				$ret = array_merge($ret, $this->flattenOptions($v, $prefix . $k . '.'));

				continue;
			}

			$ret[$prefix . $k] = $v;
		}

		return $ret;
	}
}
