<?php

namespace XF\Entity;

use XF\Behavior\DevOutputWritable;
use XF\Mvc\Entity\AbstractCollection;
use XF\Mvc\Entity\Entity;
use XF\Mvc\Entity\Structure;
use XF\Phrase;
use XF\Repository\AddOnRepository;
use XF\Repository\OptionRepository;
use XF\Repository\UserRepository;
use XF\Util\Arr;
use XF\Util\Php;

use function call_user_func, call_user_func_array, count, floatval, intval, is_array, is_null, strval;

/**
 * COLUMNS
 * @property string $option_id
 * @property string $option_value_
 * @property string $default_value_
 * @property string $edit_format
 * @property string $edit_format_params
 * @property string $data_type
 * @property array $sub_options
 * @property string $validation_class
 * @property string $validation_method
 * @property bool $advanced
 * @property string $addon_id
 *
 * GETTERS
 * @property-read array $formatParams
 * @property array|bool|int|float|string|null $option_value
 * @property array|bool|int|float|string|null $default_value
 * @property-read Phrase $title
 * @property-read Phrase $explain
 *
 * RELATIONS
 * @property-read AbstractCollection<OptionGroupRelation> $Relations
 * @property-read AddOn|null $AddOn
 * @property-read \XF\Entity\Phrase|null $MasterTitle
 * @property-read \XF\Entity\Phrase|null $MasterExplain
 */
class Option extends Entity
{
	public function canEdit()
	{
		return \XF::$developmentMode;
	}

	public function isDataTypeNumeric()
	{
		switch ($this->data_type)
		{
			case 'integer':
			case 'numeric':
			case 'unsigned_integer':
			case 'unsigned_numeric':
			case 'positive_integer':
				return true;
			default:
				return false;
		}
	}

	public function renderDisplayCallback(array $htmlParams)
	{
		if ($this->edit_format != 'callback')
		{
			return '';
		}

		$callback = $this->getFormatParams();
		if (!isset($callback['class']) || !isset($callback['method']))
		{
			return '';
		}

		if (!Php::validateCallbackPhrased($callback['class'], $callback['method'], $error))
		{
			return '';
		}

		$callback['class'] = \XF::extension()->extendClass($callback['class']);

		return call_user_func([$callback['class'], $callback['method']], $this, $htmlParams);
	}

	public function getDisplayCallbackError()
	{
		if ($this->edit_format != 'callback')
		{
			return "$this->option_id - Not a callback option.";
		}

		$callback = $this->getFormatParams();
		if (!isset($callback['class']) || !isset($callback['method']))
		{
			return "$this->option_id - No callback found.";
		}

		if (!Php::validateCallbackPhrased($callback['class'], $callback['method'], $error))
		{
			return "$this->option_id - " . $error;
		}

		return false;
	}

	/**
	 * @return array
	 */
	public function getFormatParams()
	{
		$formatParams = [];

		switch ($this->edit_format)
		{
			case 'template':
				if (strpos($this->edit_format_params, '=') === false)
				{
					$formatParams = ['template' => $this->edit_format_params];
					break;
				}
				// no break

			case 'textbox':
			case 'textarea':
			case 'spinbox':
			case 'radio':
			case 'select':
			case 'checkbox':
			case 'onofftextbox':
			case 'username':
				$formatParams = $this->app()->stringFormatter()->createKeyValueSetFromString($this->edit_format_params);
				break;

			case 'callback':
				$callback = explode('::', $this->edit_format_params);
				if (count($callback) == 2)
				{
					$formatParams = ['class' => $callback[0], 'method' => $callback[1]];
				}
				break;
		}

		return $formatParams;
	}

	/**
	 * @return array|bool|int|float|string|null
	 */
	public function getOptionValue()
	{
		$v = $this->getValue('option_value');

		if ($this->isDataTypeNumeric())
		{
			switch ($this->data_type)
			{
				case 'integer':
				case 'unsigned_integer':
				case 'positive_integer':
					return intval($v);

				default: // numeric, unsigned_numeric
					return strval(floatval($v)) + 0;
			}
		}
		else if ($this->data_type === 'boolean')
		{
			return (bool) $v;
		}
		else if ($this->data_type === 'array')
		{
			$value = json_decode($v, true);
			if (!is_array($value))
			{
				return [];
			}

			return $value;
		}

		return $v;
	}

	/**
	 * @return array|bool|int|float|string|null
	 */
	public function getDefaultValue()
	{
		$v = $this->getValue('default_value');

		if ($this->isDataTypeNumeric())
		{
			switch ($this->data_type)
			{
				case 'integer':
				case 'unsigned_integer':
				case 'positive_integer':
					return intval($v);

				default: // numeric, unsigned_numeric
					return strval(floatval($v)) + 0;
			}
		}
		else if ($this->data_type === 'boolean')
		{
			return (bool) $v;
		}
		else if ($this->data_type === 'array')
		{
			$value = json_decode($v, true);
			if (!is_array($value))
			{
				return [];
			}

			return $value;
		}

		return $v;
	}

	protected function verifyOptionValue(&$optionValue)
	{
		$this->prepareUsernameOptionValue($optionValue);

		$optionValue = $this->castOptionValue($optionValue);

		$validationClass = $this->validation_class;
		$validationMethod = $this->validation_method;

		if ($validationClass && $validationMethod && $this->getOption('verify_value'))
		{
			if (!Php::validateCallbackPhrased($validationClass, $validationMethod, $error))
			{
				$this->error($error, 'option_value');
				return false;
			}

			$validationClass = \XF::extension()->extendClass($validationClass);

			$success = (bool) call_user_func_array(
				[$validationClass, $validationMethod],
				[&$optionValue, $this, $this->option_id]
			);
			if (!$success)
			{
				return false;
			}
		}

		if ($this->data_type == 'array')
		{
			$newOptionValue = $this->validateArraySubOptions($optionValue, $subOptionError);
			if ($subOptionError)
			{
				$this->error($subOptionError, 'sub_options');
				return false;
			}

			$optionValue = json_encode($newOptionValue);
		}

		return true;
	}

	protected function prepareUsernameOptionValue(&$optionValue)
	{
		if ($this->edit_format != 'username')
		{
			return;
		}

		switch ($this->data_type)
		{
			case 'string':
				// store as is, as a username string
				return;

			case 'integer':
			case 'numeric':
			case 'unsigned_integer':
			case 'unsigned_numeric':
			case 'positive_integer':
				if ($optionValue)
				{
					$user = $this->em()->findOne(User::class, ['username' => $optionValue]);
					if (!$user)
					{
						$this->error(\XF::phrase('requested_user_x_not_found', ['name' => $optionValue]), 'option_value');
						$optionValue = [];
						return;
					}
					$optionValue = $user->user_id;
				}
				else
				{
					$optionValue = 0;
				}
				return;

			case 'array':
				$userRepo = $this->repository(UserRepository::class);

				$usernames = Arr::stringToArray($optionValue, '#\s*,\s*#');
				if ($usernames)
				{
					$users = $userRepo->getUsersByNames($usernames, $notFound, [], false);

					if ($notFound)
					{
						$this->error(\XF::phrase(
							'following_users_not_found_x',
							['usernames' => implode(', ', $notFound)]
						), 'option_value');
						$optionValue = [];
						return;
					}

					$optionValue = $users->keys();
				}
				else
				{
					$optionValue = [];
				}
				return;
		}
	}

	protected function castOptionValue($optionValue)
	{
		switch ($this->data_type)
		{
			case 'string':  $optionValue = strval($optionValue); break;
			case 'integer': $optionValue = intval($optionValue); break;
			case 'numeric': $optionValue = strval(floatval($optionValue)) + 0; break;
			case 'boolean': $optionValue = ($optionValue ? 1 : 0); break;

			case 'array':
				if ($optionValue === false || $optionValue === null)
				{
					// for all checkbox options, this can happen so allow it
					$optionValue = [];
				}
				else if (!is_array($optionValue))
				{
					throw new \LogicException("Only arrays can be set to array type options");
				}
				break;

			case 'unsigned_integer':
				$optionValue = max(0, intval($optionValue));
				break;

			case 'unsigned_numeric':
				$optionValue = strval(floatval($optionValue)) + 0;
				$optionValue = max(0, $optionValue);
				break;

			case 'positive_integer':
				$optionValue = max(1, intval($optionValue));
				break;

			default:
				throw new \LogicException("Unknown option data type $this->data_type");
		}

		return $optionValue;
	}

	protected function validateArraySubOptions(array $optionValue, &$error)
	{
		$error = null;
		$newOptionValue = [];
		$allowAny = false;

		foreach ($this->sub_options AS $subOption)
		{
			$subOption = trim($subOption);
			if ($subOption === '')
			{
				continue;
			}

			if ($subOption == '*')
			{
				$allowAny = true;
			}
			else if (!isset($optionValue[$subOption]))
			{
				$newOptionValue[$subOption] = false;
			}
			else
			{
				$newOptionValue[$subOption] = $optionValue[$subOption];
				unset($optionValue[$subOption]);
			}
		}

		if ($allowAny)
		{
			// allow any keys, so bring all the remaining ones over
			$newOptionValue += $optionValue;
		}
		else if (count($optionValue) > 0)
		{
			$error = \XF::phrase(
				'following_sub_options_unknown_x',
				['subOptions' => implode(', ', array_keys($optionValue))]
			);
		}

		return $newOptionValue;
	}

	/**
	 * @return Phrase
	 */
	public function getTitle()
	{
		return \XF::phrase($this->getPhraseName(true));
	}

	/**
	 * @return Phrase
	 */
	public function getExplain()
	{
		return \XF::phrase($this->getPhraseName(false));
	}

	public function getPhraseName($title)
	{
		return 'option' . ($title ? '' : '_explain') . '.' . $this->option_id;
	}

	public function getMasterPhrase($title)
	{
		$phrase = $title ? $this->MasterTitle : $this->MasterExplain;
		if (!$phrase)
		{
			$phrase = $this->_em->create(\XF\Entity\Phrase::class);
			$phrase->addon_id = $this->_getDeferredValue(function () { return $this->addon_id; });
			$phrase->title = $this->_getDeferredValue(function () use ($title) { return $this->getPhraseName($title); });
			$phrase->language_id = 0;
		}

		return $phrase;
	}

	public function updateRelations(array $relationMap)
	{
		if (!$this->exists())
		{
			throw new \LogicException("Option must be saved first");
		}

		$optionId = $this->option_id;
		$insert = [];
		foreach ($relationMap AS $groupId => $displayOrder)
		{
			$insert[] = [
				'option_id' => $optionId,
				'group_id' => $groupId,
				'display_order' => $displayOrder,
			];
		}

		$db = $this->db();
		$db->delete('xf_option_group_relation', 'option_id = ?', $this->option_id);
		if ($insert)
		{
			$db->insertBulk('xf_option_group_relation', $insert);
		}

		unset($this->_relations['Relations']);

		// Ensure the option relations get written to dev output.
		if ($this->getBehavior(DevOutputWritable::class)->getOption('write_dev_output'))
		{
			$devOutput = $this->app()->developmentOutput();
			$devOutput->export($this);
		}
	}

	public function validateDefaultValue($defaultValue, &$error = null)
	{
		$error = null;

		if ($this->data_type === 'array')
		{
			if (!is_array($defaultValue))
			{
				$defaultValue = json_decode($defaultValue, true);
				if (!is_array($defaultValue))
				{
					$defaultValue = [];
				}
			}
		}

		$defaultValue = $this->castOptionValue($defaultValue);

		if ($this->data_type === 'array')
		{
			$defaultValue = $this->validateArraySubOptions($defaultValue, $subOptionsError);
			if ($subOptionsError)
			{
				$error = $subOptionsError;
			}

			$defaultValue = json_encode($defaultValue);
		}

		return strval($defaultValue);
	}

	protected function _preSave()
	{
		if ($this->getOption('verify_validation_callback')
			&& ($this->validation_class || $this->validation_method)
			&& ($this->isChanged('validation_class') || $this->isChanged('validation_method')))
		{
			if (!Php::validateCallbackPhrased($this->validation_class, $this->validation_method, $error))
			{
				$this->error($error, 'validation_method');
			}
		}

		if ($this->isChanged('data_type') || $this->isChanged('edit_format'))
		{
			$this->validateDataTypeEditFormat($this->data_type, $this->edit_format);
		}

		if ($this->isChanged('edit_format_params'))
		{
			$this->validateEditFormatParams($this->edit_format, $this->edit_format_params);
		}

		if ($this->data_type == 'array' && !$this->sub_options)
		{
			if ($this->edit_format == 'username')
			{
				$this->sub_options = ['*'];
			}
			else
			{
				$this->error(\XF::phrase('please_enter_list_of_sub_options_for_this_array'), 'sub_options');
			}
		}
		else if ($this->data_type != 'array')
		{
			$this->sub_options = [];
		}

		if ($this->isChanged(['default_value', 'sub_options', 'data_type']))
		{
			$defaultValue = $this->validateDefaultValue($this->getValue('default_value'), $defaultValueError);
			if ($defaultValueError && \XF::$developmentMode)
			{
				$this->error(
					\XF::string([\XF::phrase('default_value:'), $defaultValueError]),
					'default_value'
				);
			}
			else
			{
				$this->default_value = $defaultValue;
			}
		}

		if ($this->isInsert() && !$this->isChanged('option_value'))
		{
			$this->_setInternal('option_value', $this->getValue('default_value'));
		}

		if ($this->isUpdate()
			&& $this->_newValues
			&& count($this->_newValues) === 1
			&& $this->isChanged('option_value')
		)
		{
			// only the option value has changed so prevent dev output from being written
			/** @var DevOutputWritable $devOutputWritable */
			$devOutputWritable = $this->getBehavior(DevOutputWritable::class);
			$devOutputWritable->setOption('write_dev_output', false);
		}
	}

	public function error($message, $key = null, $specificError = true)
	{
		if (is_null($key))
		{
			$key = $this->option_id;
		}

		parent::error($message, $key, $specificError);
	}

	protected function validateDataTypeEditFormat($dataType, $editFormat)
	{
		switch ($editFormat)
		{
			case 'callback':
			case 'template':
				// can be anything
				break;

			case 'checkbox':
			case 'onofftextbox':
				if ($dataType != 'array')
				{
					$this->error(\XF::phrase('please_select_data_type_array_if_you_want_to_allow_multiple_selections'), 'data_type');
					return false;
				}
				break;

			case 'textbox':
			case 'spinbox':
			case 'onoff':
			case 'radio':
			case 'select':
				if ($dataType == 'array')
				{
					$this->error(\XF::phrase('please_select_data_type_other_than_array_if_you_want_to_allow_single'), 'data_type');
					return false;
				}
				break;
		}

		return true;
	}

	protected function validateEditFormatParams($editFormat, $editFormatParams)
	{
		switch ($editFormat)
		{
			case 'template':
				if (!preg_match('/^\w+$/i', $editFormatParams) && !preg_match('/template\s*=\s*\w+/i', $editFormatParams))
				{
					$this->error(\XF::phrase('template_edit_format_params_invalid'), 'data_type');
					return false;
				}
				break;
		}

		return true;
	}

	protected function _postSave()
	{
		if ($this->isUpdate())
		{
			if ($this->isChanged('addon_id') || $this->isChanged('option_id'))
			{
				$writeDevOutput = $this->getBehavior(DevOutputWritable::class)->getOption('write_dev_output');

				/** @var Phrase $titlePhrase */
				$titlePhrase = $this->getExistingRelation('MasterTitle');
				if ($titlePhrase)
				{
					$titlePhrase->getBehavior(DevOutputWritable::class)->setOption('write_dev_output', $writeDevOutput);

					$titlePhrase->addon_id = $this->addon_id;
					$titlePhrase->title = $this->getPhraseName(true);
					$titlePhrase->save();
				}

				/** @var Phrase $explainPhrase */
				$explainPhrase = $this->getExistingRelation('MasterExplain');
				if ($explainPhrase)
				{
					$explainPhrase->getBehavior(DevOutputWritable::class)->setOption('write_dev_output', $writeDevOutput);

					$explainPhrase->addon_id = $this->addon_id;
					$explainPhrase->title = $this->getPhraseName(false);
					$explainPhrase->save();
				}
			}

			if ($this->isChanged('option_id'))
			{
				$this->db()->update(
					'xf_option_group_relation',
					['option_id' => $this->option_id],
					'option_id = ?',
					$this->getExistingValue('option_id')
				);
			}
		}

		$this->rebuildOptionCache();
	}

	protected function _postDelete()
	{
		$writeDevOutput = $this->getBehavior(DevOutputWritable::class)->getOption('write_dev_output');

		$titlePhrase = $this->MasterTitle;
		if ($titlePhrase)
		{
			$titlePhrase->getBehavior(DevOutputWritable::class)->setOption('write_dev_output', $writeDevOutput);

			$titlePhrase->delete();
		}
		$explainPhrase = $this->MasterExplain;
		if ($explainPhrase)
		{
			$explainPhrase->getBehavior(DevOutputWritable::class)->setOption('write_dev_output', $writeDevOutput);

			$explainPhrase->delete();
		}

		$this->db()->delete('xf_option_group_relation', 'option_id = ?', $this->option_id);

		$this->rebuildOptionCache();
	}

	protected function rebuildOptionCache()
	{
		$repo = $this->getOptionRepo();

		\XF::runOnce('optionCacheRebuild', function () use ($repo)
		{
			$repo->rebuildOptionCache();
		});
	}

	protected function _setupDefaults()
	{
		$addOnRepo = $this->_em->getRepository(AddOnRepository::class);
		$this->addon_id = $addOnRepo->getDefaultAddOnId();
	}

	public static function getStructure(Structure $structure)
	{
		$structure->table = 'xf_option';
		$structure->shortName = 'XF:Option';
		$structure->primaryKey = 'option_id';
		$structure->columns = [
			'option_id' => ['type' => self::STR, 'maxLength' => 50,
				'required' => 'please_enter_valid_option_id',
				'unique' => 'option_ids_must_be_unique',
				'match' => self::MATCH_ALPHANUMERIC,
			],
			'option_value' => ['type' => self::BINARY, 'default' => ''],
			'default_value' => ['type' => self::BINARY, 'default' => ''],
			'edit_format' => ['type' => self::STR, 'required' => true,
				'allowedValues' => ['textbox', 'spinbox', 'onoff', 'onofftextbox', 'radio', 'select', 'checkbox', 'template', 'callback', 'username'],
			],
			'edit_format_params' => ['type' => self::STR, 'default' => ''],
			'data_type' => ['type' => self::STR, 'required' => true,
				'allowedValues' => ['string', 'integer', 'numeric', 'array', 'boolean', 'positive_integer', 'unsigned_integer', 'unsigned_numeric'],
				'default' => 'string',
			],
			'sub_options' => ['type' => self::LIST_LINES, 'default' => ''],
			'validation_class' => ['type' => self::STR, 'maxLength' => 100, 'default' => ''],
			'validation_method' => ['type' => self::STR, 'maxLength' => 75, 'default' => ''],
			'advanced' => ['type' => self::BOOL, 'default' => false],
			'addon_id' => ['type' => self::BINARY, 'maxLength' => 50, 'default' => ''],
		];
		$structure->behaviors = [
			'XF:DevOutputWritable' => [],
		];
		$structure->getters = [
			'formatParams' => true,
			'option_value' => true,
			'default_value' => true,
			'title' => true,
			'explain' => true,
		];
		$structure->relations = [
			'Relations' => [
				'entity' => 'XF:OptionGroupRelation',
				'type' => self::TO_MANY,
				'conditions' => 'option_id',
				'key' => 'group_id',
			],
			'AddOn' => [
				'entity' => 'XF:AddOn',
				'type' => self::TO_ONE,
				'conditions' => 'addon_id',
				'primary' => true,
			],
			'MasterTitle' => [
				'entity' => 'XF:Phrase',
				'type' => self::TO_ONE,
				'conditions' => [
					['language_id', '=', 0],
					['title', '=', 'option.', '$option_id'],
				],
			],
			'MasterExplain' => [
				'entity' => 'XF:Phrase',
				'type' => self::TO_ONE,
				'conditions' => [
					['language_id', '=', 0],
					['title', '=', 'option_explain.', '$option_id'],
				],
			],
		];
		$structure->options = [
			'verify_validation_callback' => true,
			'verify_value' => true,
		];

		return $structure;
	}

	/**
	 * @return OptionRepository
	 */
	protected function getOptionRepo()
	{
		return $this->_em->getRepository(OptionRepository::class);
	}
}
