<?php

namespace XF\Legacy;

use XF\Db\AbstractAdapter;
use XF\Legacy\DataWriter\Phrase as XenForo_DataWriter_Phrase;
use XF\Legacy\Model\AdminSearch;
use XF\Legacy\Model\Alert;
use XF\Legacy\Model\NewsFeed;
use XF\Legacy\Model\User;
use XF\Legacy\Model\UserIgnore;
use XF\Phrase;
use XF\PrintableException;
use XF\Util\Str;

use function array_key_exists, call_user_func_array, count, in_array, intval, is_array, is_integer, is_scalar, is_string, sizeof, strval;

abstract class DataWriter
{
	/**
	* Constant for error handling. Use this to trigger an exception when an error occurs.
	*
	* @var integer
	*/
	public const ERROR_EXCEPTION = 1;

	/**
	* Constant for error handling. Use this to push errors onto an array. If you try
	* to save while there are errors, an exception will be thrown.
	*
	* @var integer
	*/
	public const ERROR_ARRAY = 2;

	/**
	* Constant for error handling. Use this to push errors onto an array. If you try
	* to save while there are errors, the save will silently fail. Use this in places
	* where you aren't interested in handling errors.
	*
	* @var integer
	*/
	public const ERROR_SILENT = 3;

	/**
	 * Constant for data fields. Use this for 0/1 boolean integer fields.
	 *
	 * @var string
	 */
	public const TYPE_BOOLEAN = 'boolean';

	/**
	* Constant for data fields. Use this for string fields. String fields are assumed
	* to be UTF-8 and limits will refer to characters.
	*
	* @var string
	*/
	public const TYPE_STRING = 'string';

	/**
	* Constant for data fields. Use this for binary or ASCII-only fields. Limits
	* will refer to bytes.
	*
	* @var string
	*/
	public const TYPE_BINARY = 'binary';

	/**
	* Constant for data fields. Use this for integer fields. Limits can be applied
	* on the range of valid values.
	*
	* @var string
	*/
	public const TYPE_INT = 'int';

	/**
	* Constant for data fields. Use this for unsigned integer fields. Negative values
	* will always fail to be valid. Limits can be applied on the range of valid values.
	*
	* @var string
	*/
	public const TYPE_UINT = 'uint';

	/**
	 * Constant for data fields. Use this for unsigned integer fields. This differs from
	 * TYPE_UINT in that negative values will be silently cast to 0. Limits can be
	 * applied on the range of valid values.
	 *
	 * @var string
	 */
	public const TYPE_UINT_FORCED = 'uint_forced';

	/**
	* Constant for data fields. Use this for float fields. Limits can be applied
	* on the range of valid values.
	*
	* @var string
	*/
	public const TYPE_FLOAT = 'float';

	/**
	 * Data is serialized. Ensures that if the data is not a string, it is serialized to one.
	 *
	 * @var string
	 */
	public const TYPE_SERIALIZED = 'serialized';

	/**
	 * Data is serialized to JSON.
	 *
	 * @var string
	 */
	public const TYPE_JSON = 'json';

	/**
	 * Constant for data fields. Use this for fields that have a type that cannot be
	 * known statically. Use this sparingly, as you must write code to ensure that
	 * the value is a scalar before it is inserted into the DB. The behavior if you
	 * don't do this is not defined!
	 *
	 * @var string
	 */
	public const TYPE_UNKNOWN = 'unknown';

	/**
	 * Database object
	 *
	 * @var AbstractAdapter
	 */
	protected $_db = null;

	/**
	* Array of valid fields in the table. See {@link _getFields()} for more info.
	*
	* @var array
	*/
	protected $_fields = [];

	/**
	* Options that change the behavior of the data writer. All options that a
	* data writer supports must be documented in this array, preferably
	* by the use of constants.
	*
	* @var array
	*/
	protected $_options = [];

	/**
	* Extra data that the data writer can use. The DW should usually function without
	* any data in this array. Any data that DW supports should be documented in
	* this array, preferably by the use of constants.
	*
	* Required data (for example, a related phrase) can be included here if necessary.
	*
	* @var array
	*/
	protected $_extraData = [];

	/**
	* Data that has just been set an is about to be saved.
	*
	* @var array
	*/
	protected $_newData = [];

	/**
	* Existing data in the database. This is only populated when updating a record.
	*
	* @var array
	*/
	protected $_existingData = [];

	/**
	 * When enabled, preSave, postSave, and verification functions are disabled. To be used
	 * when using the DW for importing data in bulk. Note that you are responsible for manually
	 * replicating the post save effects.
	 *
	 * @var boolean
	 */
	protected $_importMode = false;

	/**
	* Type of error handler. See {@link ERROR_EXCEPTION} and related.
	*
	* @var integer
	*/
	protected $_errorHandler = 0;

	/**
	* Array of errors. This is always populated when an error occurs, though
	* an exception may be thrown as well.
	*
	* @var array
	*/
	protected $_errors = [];

	/**
	* Tracks whether {@link _preSave()} was called. It can only be called once.
	*
	* @var boolean
	*/
	protected $_preSaveCalled = false;

	/**
	* Tracks whether {@link _preDelete()} was called. It can only be called once.
	*
	* @var boolean
	*/
	protected $_preDeleteCalled = false;

	/**
	* Options that can be passed into {@link set()}. Available options are:
	* (default value is listed in parentheses)
	*	* ignoreInvalidFields (false) - when true, an invalid field is simply ignored; otherwise an error is triggered
	*	* replaceInvalidWithDefault (false) - when true, invalid values are replaced with the default value (if there is one)
	*	* runVerificationCallback (true) - when true, verification callbacks are run when setting
	*	* setAfterPreSave (false) - when true, you may set data after preSave is called. This has very specific uses.
	*
	* @var array
	*/
	protected $_setOptions = [
		'ignoreInvalidFields' => false,
		'replaceInvalidWithDefault' => false,
		'runVerificationCallback' => true,
		'setAfterPreSave' => false,
	];

	/**
	 * Title of the phrase that will be created when a call to set the
	 * existing data fails (when the data doesn't exist).
	 *
	 * @var string
	 */
	protected $_existingDataErrorPhrase = 'existing_data_required_data_writer_not_found';

	/**
	 * Standard approach to caching model objects for the lifetime of the data writer.
	 * This is now a static cache to allow data to be reused between data writers, as they
	 * are often used in bulk.
	 *
	 * @var array
	 */
	protected static $_modelCache = [];

	/**
	 * If true, a language rebuild is triggered at the very end of a save/delete process.
	 *
	 * @var boolean
	 */
	protected $_triggerLanguageRebuild = false;

	/**
	* Constructor.
	*
	* @param int $errorHandler Error handler. See {@link ERROR_EXCEPTION} and related.
	* @param array|null $inject Dependency injector. Array keys available: db, cache.
	*/
	public function __construct($errorHandler = self::ERROR_ARRAY, ?array $inject = null)
	{
		$this->_db = \XF::db();

		$this->setErrorHandler($errorHandler);

		$fields = $this->_getFields();
		if (is_array($fields))
		{
			$this->_fields = $fields;
		}

		$options = $this->_getDefaultOptions();
		if (is_array($options))
		{
			$this->_options = $options;
		}
	}

	/**
	* Gets the fields that are defined for the table. This should return an array with
	* each key being a table name and a further array with each key being a field in database.
	* The value of each entry should be an array, which
	* may have the following keys:
	*	* type - one of the TYPE_* constants, this controls the type of data in the field
	*	* autoIncrement - set to true when the field is an auto_increment field. Used to populate this field after an insert
	*	* required - if set, inserts will be prevented if this field is not set or an empty string (0 is accepted)
	*	* default - the default value of the field; used if the field isn't set or if the set value is outside the constraints (if a {@link $_setOption} is set)
	*	* maxLength - for string/binary types only, the maximum length of the data. For strings, in characters; for binary, in bytes.
	*	* min - for numeric types only, the minimum value allowed (inclusive)
	*	* max - for numeric types only, the maximum value allowed (inclusive)
	*	* allowedValues - an array of allowed values for this field (commonly for enums)
	*	* verification - a callback to do more advanced verification. Callback function will take params for $value and $this.
	*
	* @return array
	*/
	abstract protected function _getFields();

	public function getFields()
	{
		return $this->_getFields();
	}

	public function getFieldNames($tableName = null)
	{
		$tables = $this->_getFields();

		if (!empty($tableName))
		{
			if (empty($tables[$tableName]))
			{
				$this->error("No fields are defined for table '{$tableName}'.");
			}

			return array_keys($tables[$tableName]);
		}

		$fieldNames = [];

		foreach ($tables AS $fields)
		{
			foreach ($fields AS $fieldName => $fieldInfo)
			{
				$fieldNames[] = $fieldName;
			}
		}

		return array_unique($fieldNames);
	}

	/**
	* Gets the actual existing data out of data that was passed in. This data
	* may be a scalar or an array. If it's a scalar, assume that it is the primary
	* key (if there is one); if it is an array, attempt to extract the primary key
	* (or some other unique identifier). Then fetch the correct data from a model.
	*
	* @param mixed $data Data that can uniquely ID this item
	*
	* @return array|false
	*/
	abstract protected function _getExistingData($data);

	/**
	* Gets SQL condition to update the existing record. Should read from {@link _existingData}.
	*
	* @param string $tableName Table name
	*
	* @return string
	*/
	abstract protected function _getUpdateCondition($tableName);

	/**
	* Get the primary key from either an array of data or just the scalar value
	*
	* @param mixed $data Array of data containing the primary field or a scalar ID
	* @param string $primaryKeyField Primary key field, if data is an array
	* @param string $tableName Table name, if empty the first table defined in fields is used
	*
	* @return string|false
	*/
	protected function _getExistingPrimaryKey($data, $primaryKeyField = '', $tableName = '')
	{
		if (!$tableName)
		{
			$tableName = $this->_getPrimaryTable();
		}

		if (!$primaryKeyField)
		{
			$primaryKeyField = $this->_getAutoIncrementField($tableName);
		}

		if (!isset($this->_fields[$tableName][$primaryKeyField]))
		{
			return false;
		}

		if (is_array($data))
		{
			if (array_key_exists($primaryKeyField, $data))
			{
				return $data[$primaryKeyField];
			}
			else
			{
				return false;
			}
		}
		else
		{
			return $data;
		}
	}

	/**
	 * Splits a (complete) data record array into its constituent tables using the _fields array for use in _getExistingData
	 *
	 * In order for this to work, the following must be true:
	 * 1) The input array must provide all data that this datawriter requires
	 * 2) There can be no overlapping field names in the tables that define this data, unless those fields all store the same value
	 * 3) IMPORTANT: If the input array does *not* include all the required data fields, it is your responsibility to provide it after this function returns.
	 *
	 * @param array $dataArray Complete data record
	 *
	 * @return array
	 */
	public function getTablesDataFromArray(array $dataArray)
	{
		$data = [];

		/**
		 * there are no dupe fieldnames between the tables,
		 * so we might as well populate the existing data using the existing _fields array
		 */
		foreach ($this->_fields AS $tableName => $field)
		{
			$data[$tableName] = [];

			foreach ($field AS $fieldName => $fieldInfo)
			{
				/**
				 * don't attempt to set fields that are not provided by $dataArray,
				 * it is your own responsibility to resolve this afterwards the function returns
				 */
				if (isset($dataArray[$fieldName]))
				{
					$data[$tableName][$fieldName] = $dataArray[$fieldName];
				}
			}
		}

		return $data;
	}

	/**
	* Gets the default set of options for this data writer. This is automatically
	* called in the constructor. If you wish to set options in your data writer,
	* override this function. As this is handled at run time rather than compile time,
	* you may use dynamic behaviors to set the option defaults.
	*
	* @return array
	*/
	protected function _getDefaultOptions()
	{
		return [];
	}

	/**
	* Sets the default options for {@link set()}. See {@link $_setOptions} for
	* the available options.
	*
	* @param array
	*/
	public function setDefaultSetOptions(array $setOptions)
	{
		$this->_setOptions = array_merge($this->_setOptions, $setOptions);
	}

	/**
	* Sets an option. The meaning of this option is completely domain specific.
	* If an unknown option specified, an exception will be triggered.
	*
	* @param string $name Name of the option to set
	* @param mixed  $value Value of the option
	*/
	public function setOption($name, $value)
	{
		if (!array_key_exists($name, $this->_options))
		{
			throw new \InvalidArgumentException('Cannot set unknown option');
		}

		$this->_options[$name] = $value;
	}

	/**
	* Gets an option. The meaning of this option is completely domain specific.
	* If an unknown option specified, an exception will be triggered.
	*
	* @param string $name Name of the option to get
	*
	* @return mixed  Value of the option
	*/
	public function getOption($name)
	{
		if (!array_key_exists($name, $this->_options))
		{
			throw new \InvalidArgumentException('Cannot get unknown option ' . $name);
		}

		return $this->_options[$name];
	}

	/**
	* Sets extra data that the DW can use to help it. The DW should be able
	* to function without this data.
	*
	* @param string $name Name of the data
	* @param mixed  $value Value of the data
	*/
	public function setExtraData($name, $value)
	{
		$this->_extraData[$name] = $value;
	}

	/**
	 * Gets the named piece of extra data.
	 *
	 * @param string $name
	 *
	 * @return mixed
	 */
	public function getExtraData($name)
	{
		return $this->isExtraDataSet($name) ? $this->_extraData[$name] : null;
	}

	/**
	 * Determines whether or not the specified extra data field has been set at all
	 *
	 * @param string $name
	 *
	 * @return boolean
	 */
	public function isExtraDataSet($name)
	{
		return array_key_exists($name, $this->_extraData);
	}

	/**
	 * Sets the import mode. When enabled, preSave, postSave, and verification functions are disabled.
	 *
	 * @param boolean$mode
	 */
	public function setImportMode($mode)
	{
		$this->_importMode = $mode;
	}

	/**
	* Sets the existing data. This causes the system to do an update instead of an insert.
	* This function triggers an error if no data can be fetched from the provided data.
	*
	* @param mixed   $data Data that can uniquely ID this item
	* @param boolean $trustInputAsPrimary If true, trust the passed data to be based on data in DB; if false, the data is used as is (if it's an array)
	*
	* @return boolean
	*/
	public function setExistingData($data, $trustInputAsPrimary = false)
	{
		if ($trustInputAsPrimary && is_array($data) && sizeof(array_keys($this->_fields)) == 1)
		{
			// don't force standardization and given an array, so use it as is
			$existing = [$this->_getPrimaryTable() => $data];
		}
		else
		{
			$existing = $this->_getExistingData($data);
			if (is_array($existing) && !array_key_exists($this->_getPrimaryTable(), $existing))
			{
				throw new \InvalidArgumentException('_getExistingData returned an array but did not include data for the primary table');
			}
		}

		// data is only valid if the data is an array and every entry (table) is an array as well
		$validData = true;
		if (!is_array($existing))
		{
			$validData = false;
		}
		else
		{
			foreach ($existing AS $tableData)
			{
				if (!is_array($tableData))
				{
					$validData = false;
					break;
				}
			}
		}

		if ($validData)
		{
			$this->_existingData = $existing;
			return true;
		}
		else
		{
			$this->_triggerInvalidExistingDataError();
			return false;
		}
	}

	/**
	 * Triggers the error for when invalid existing data was requested. This can (and generally
	 * should) be extended by concrete DWs to give an error that is more specific to
	 * their content type.
	 */
	protected function _triggerInvalidExistingDataError()
	{
		$this->error(\XF::phrase($this->_existingDataErrorPhrase));
	}

	/**
	* Sets a field. This value will be updated when save is called.
	*
	* @param string $field Name of field to update
	* @param string $value Value to update with
	* @param string $tableName Table name, if empty then all tables with that column
	* @param array  $options Options. See {@link $_setOptions).
	*
	* @return boolean
	*/
	public function set($field, $value, $tableName = '', ?array $options = null)
	{
		$options = (is_array($options) ? array_merge($this->_setOptions, $options) : $this->_setOptions);

		if ($this->_preSaveCalled && empty($options['setAfterPreSave']))
		{
			throw new \LogicException('Set cannot be called after preSave has been called.');
		}

		$validField = false;
		foreach ($this->_fields AS $table => $fields)
		{
			if ($tableName && $tableName != $table)
			{
				continue;
			}

			if (isset($fields[$field]) && is_array($fields[$field]))
			{
				$validField = true;
				$newValue = $value;

				if ($this->_isFieldValueValid($field, $fields[$field], $newValue, $options))
				{
					$this->_setInternal($table, $field, $newValue);
				}
			}
		}

		if (!$validField && empty($options['ignoreInvalidFields']))
		{
			$this->error("The field '$field' was not recognised.", $field, false);
		}

		return $validField;
	}

	/**
	 * Internal function to set a field without any validation or type casting.
	 * Use only when you're sure validation, etc isn't needed. The field will only
	 * be set if the value has changed.
	 *
	 * @param string $table Table the field belongs to
	 * @param string $field Name of the field
	 * @param mixed $newValue Value for the field
	 * @param boolean $forceSet If true, the set always goes through
	 */
	protected function _setInternal($table, $field, $newValue, $forceSet = false)
	{
		$existingValue = $this->get($field, $table);
		if ($forceSet
			|| $existingValue === null
			|| !is_scalar($newValue)
			|| !is_scalar($existingValue)
			|| strval($newValue) !== strval($existingValue)
		)
		{
			if ($newValue === $this->getExisting($field, $table))
			{
				unset($this->_newData[$table][$field]);
			}
			else
			{
				$this->_newData[$table][$field] = $newValue;
			}
		}
	}

	/**
	 * Internal helper for calling set after save and not triggering an error.
	 *
	 * @param string $field Name of field to update
	 * @param string $newValue Value to update with
	 * @param string $tableName Table name, if empty then all tables with that column
	 * @param array  $options Options. See {@link $_setOptions).
	 *
	 * @return boolean
	 */
	protected function _setPostSave($field, $newValue, $tableName = '', array $options = [])
	{
		$options['setAfterPreSave'] = true;

		return $this->set($field, $newValue, $tableName, $options);
	}

	/**
	* Determines if the provided value for a field is valid. The value may be
	* modified based on type, contraints, or other verification methods.
	*
	* @param string $fieldName Name of the field.
	* @param array  $fieldData Data about the field (includes type, contraints, callback, etc)
	* @param mixed  $value Value for the field
	* @param array  $options Options. Uses {@link $_setOptions}.
	*
	* @return boolean
	*/
	protected function _isFieldValueValid($fieldName, array $fieldData, &$value, array $options = [])
	{
		$fieldType = $fieldData['type'] ?? self::TYPE_BINARY;
		$value = $this->_castValueToType($fieldType, $value, $fieldName, $fieldData);

		if (!empty($options['runVerificationCallback']) && !empty($fieldData['verification']))
		{
			if (!$this->_runVerificationCallback($fieldData['verification'], $value, $fieldData, $fieldName))
			{
				// verification callbacks are responsible for throwing errors
				return false;
			}
		}

		$checkLimits = $this->_applyFieldValueLimits($fieldType, $value, $fieldData);

		if ($checkLimits !== true)
		{
			if (empty($options['replaceInvalidWithDefault']) || !array_key_exists('default', $fieldData))
			{
				$this->error($checkLimits, $fieldName, false);
				return false;
			}
			else
			{
				$value = $fieldData['default'];
			}
		}

		return true;
	}

	/**
	 * Casts the field value based on the specified type (TYPE_* constants).
	 *
	 * @param string $fieldType Type to cast to
	 * @param mixed $value Value to cast
	 * @param string $fieldName Name of the field being cast
	 * @param array $fieldData Array of all field data information, for extra options
	 *
	 * @return mixed
	 */
	protected function _castValueToType($fieldType, $value, $fieldName, array $fieldData)
	{
		switch ($fieldType)
		{
			case self::TYPE_STRING:
				if (isset($fieldData['noTrim']))
				{
					return strval($value);
				}

				return trim(strval($value));

			case self::TYPE_BINARY:
				return strval($value);

			case self::TYPE_UINT_FORCED:
				$value = intval($value);
				return ($value < 0 ? 0 : $value);

			case self::TYPE_UINT:
			case self::TYPE_INT:
				return intval($value);

			case self::TYPE_FLOAT:
				return strval($value) + 0;

			case self::TYPE_BOOLEAN:
				return ($value ? 1 : 0);

			case self::TYPE_SERIALIZED:
				if (!is_string($value))
				{
					return serialize($value);
				}

				if (@unserialize($value) === false && $value != serialize(false))
				{
					throw new \InvalidArgumentException('Value is not unserializable');
				}

				return $value;

			case self::TYPE_JSON:
				if (!is_string($value))
				{
					return json_encode($value);
				}

				if (json_decode($value) === null)
				{
					throw new \InvalidArgumentException('Value cannot be JSON decoded');
				}

				return $value;

			case self::TYPE_UNKNOWN:
				return $value; // unmodified

			default:
				throw new \InvalidArgumentException((
					($fieldName === false)
					? "There is no field type '$fieldType'."
					: "The field type specified for '$fieldName' is not valid ($fieldType)."
				));
		}
	}

	/**
	* Applies value limits to a field based on type and other constraints.
	* Returns true if the field meets the constraints. The passed in value will
	* be modified by reference.
	*
	* @param string $fieldType Type of the field. See the TYPE_* constants.
	* @param mixed  $value Value for the field.
	* @param array  $extraLimits Extra constraints
	*
	* @return boolean|string Either TRUE or an error message
	*/
	protected function _applyFieldValueLimits($fieldType, &$value, array $extraLimits = [])
	{
		// constraints
		switch ($fieldType)
		{
			case self::TYPE_STRING:
			case self::TYPE_BINARY:
				$strlenFunc = ($fieldType == self::TYPE_STRING ? 'utf8_strlen' : 'strlen');

				if (isset($extraLimits['maxLength']) && $strlenFunc($value) > $extraLimits['maxLength'])
				{
					if ($this->_importMode)
					{
						if ($strlenFunc == 'utf8_strlen')
						{
							$value = Str::substr($value, 0, $extraLimits['maxLength']);
						}
						else
						{
							$value = substr($value, 0, $extraLimits['maxLength']);
						}
					}
					else
					{
						return \XF::phrase('please_enter_value_using_x_characters_or_fewer', ['count' => $extraLimits['maxLength']]);
					}
				}
				break;

			case self::TYPE_UINT_FORCED:
			case self::TYPE_UINT:
				if ($value < 0)
				{
					if ($this->_importMode)
					{
						$value = 0;
					}
					else
					{
						return \XF::phrase('please_enter_positive_whole_number');
					}
				}
				else if ($this->_importMode && $value > 4294967295)
				{
					$value = 4294967295;
				}
				if (!array_key_exists('max', $extraLimits))
				{
					$extraLimits['max'] = 4294967295;
				}
				break;

			case self::TYPE_INT:
				if ($this->_importMode)
				{
					if ($value > 2147483647)
					{
						$value = 2147483647;
					}
					else if ($value < -2147483648)
					{
						$value = -2147483648;
					}
				}
				if (!array_key_exists('min', $extraLimits))
				{
					$extraLimits['min'] = -2147483648;
				}
				if (!array_key_exists('max', $extraLimits))
				{
					$extraLimits['max'] = 2147483647;
				}
				break;
		}

		switch ($fieldType)
		{
			case self::TYPE_UINT_FORCED:
			case self::TYPE_UINT:
			case self::TYPE_INT:
			case self::TYPE_FLOAT:
				if (isset($extraLimits['min']) && $value < $extraLimits['min'])
				{
					if ($this->_importMode)
					{
						$value = $extraLimits['min'];
					}
					else
					{
						return \XF::phrase('please_enter_number_that_is_at_least_x', ['min' => $extraLimits['min']]);
					}
				}

				if (isset($extraLimits['max']) && $value > $extraLimits['max'])
				{
					if ($this->_importMode)
					{
						$value = $extraLimits['max'];
					}
					else
					{
						return \XF::phrase('please_enter_number_that_is_no_more_than_x', ['max' => $extraLimits['max']]);
					}
				}
				break;
		}

		if (isset($extraLimits['allowedValues']) && is_array($extraLimits['allowedValues']) && !in_array($value, $extraLimits['allowedValues'], true))
		{
			return \XF::phrase('please_enter_valid_value');
		}

		return true;
	}

	/**
	* Runs the verification callback. This callback may modify the value if it chooses to.
	* Callback receives 2 params: the value and this object. The callback must return true
	* if the value was valid.
	*
	* Returns true if the verification was successful.
	*
	* @param callback $callback Callback to run. Use an array with a string '$this' to callback to this object.
	* @param mixed    $value Value to verify
	* @param array    $fieldData Information about the field, including all constraints to be applied
	* @param string $fieldName
	*
	* @return boolean
	*/
	protected function _runVerificationCallback($callback, &$value, array $fieldData, $fieldName = false)
	{
		if ($this->_importMode)
		{
			return true;
		}

		if (is_array($callback) && isset($callback[0]) && $callback[0] == '$this')
		{
			$callback[0] = $this;
		}

		return (bool) call_user_func_array(
			$callback,
			[&$value, $this, $fieldName, $fieldData]
		);
	}

	/**
	* Helper method to bulk set values from an array.
	*
	* @param array $fields Key-value pairs of fields and values.
	* @param array $options Options to pass into {@link set()}. See {@link $_setOptions}.
	*/
	public function bulkSet(array $fields, ?array $options = null)
	{
		foreach ($fields AS $field => $value)
		{
			$this->set($field, $value, '', $options);
		}
	}

	/**
	 * Sets the error handler type.
	 *
	 * @param integer $errorHandler
	 */
	public function setErrorHandler($errorHandler)
	{
		$this->_errorHandler = intval($errorHandler);
	}

	/**
	* Gets data related to this object regardless of where it is defined (new or old).
	*
	* @param string $field Field name
	* @param string $tableName Table name, if empty loops through tables until first match
	*
	* @return mixed Returns null if the specified field could not be found.
	*/
	public function get($field, $tableName = '')
	{
		$tables = $this->_getTableList($tableName);

		foreach ($tables AS $tableName)
		{
			if (isset($this->_newData[$tableName]) && array_key_exists($field, $this->_newData[$tableName]))
			{
				return $this->_newData[$tableName][$field];
			}
			else if (isset($this->_existingData[$tableName]) && array_key_exists($field, $this->_existingData[$tableName]))
			{
				return $this->_existingData[$tableName][$field];
			}
		}

		return null;
	}

	/**
	* Explictly gets data from the new data array. Returns null if not set.
	*
	* @param string $field Field name
	* @param string $tableName Table name, if empty loops through tables until first match
	* @param boolean $exists
	*
	* @return mixed
	*/
	public function getNew($field, $tableName = '', &$exists = null)
	{
		$tables = $this->_getTableList($tableName);

		foreach ($tables AS $tableName)
		{
			if (isset($this->_newData[$tableName]) && array_key_exists($field, $this->_newData[$tableName]))
			{
				$exists = true;
				return $this->_newData[$tableName][$field];
			}
		}

		$exists = false;
		return null;
	}

	/**
	 * Returns true if changes have been made to this data.
	 *
	 * @return boolean
	 */
	public function hasChanges()
	{
		return (!empty($this->_newData));
	}

	/**
	* Determines whether the named field has been changed/set.
	*
	* @param string $field Field name
	* @param string $tableName Table name, if empty loops through tables until first match
	*
	* @return boolean
	*/
	public function isChanged($field, $tableName = '')
	{
		$this->getNew($field, $tableName, $exists);
		return $exists;
	}

	/**
	* Explictly gets data from the existing data array. Returns null if not set.
	*
	* @param string $field Field name
	* @param string $tableName Table name, if empty loops through tables until first match
	* @param boolean $exists
	*
	* @return mixed
	*/
	public function getExisting($field, $tableName = '', &$exists = null)
	{
		$tables = $this->_getTableList($tableName);

		foreach ($tables AS $tableName)
		{
			if (isset($this->_existingData[$tableName]) && array_key_exists($field, $this->_existingData[$tableName]))
			{
				$exists = true;
				return $this->_existingData[$tableName][$field];
			}
		}

		$exists = false;
		return null;
	}

	/**
	* Merges the new and existing data to show a "final" view of the data. This will
	* generally reflect what is in the database.
	*
	* If no table is specified, all data will be flattened into one array. New data
	* takes priority over existing data, and earlier tables in the list take priority
	* over later tables.
	*
	* @param string $tableName
	*
	* @return array
	*/
	public function getMergedData($tableName = '')
	{
		$tables = $this->_getTableList($tableName);

		$output = [];

		// loop through all tables and use the first value that comes up for a field.
		// this assumes that the more "primary" tables come first
		foreach ($tables AS $tableName)
		{
			if (isset($this->_newData[$tableName]))
			{
				$output += $this->_newData[$tableName];
			}

			if (isset($this->_existingData[$tableName]))
			{
				$output += $this->_existingData[$tableName];
			}
		}

		return $output;
	}

	/**
	 * Gets the existing data only into a flat view of the data.
	 *
	 * @param string $tableName
	 *
	 * @return array
	 */
	public function getMergedExistingData($tableName = '')
	{
		$tables = $this->_getTableList($tableName);

		$output = [];

		// loop through all tables and use the first value that comes up for a field.
		// this assumes that the more "primary" tables come first
		foreach ($tables AS $tableName)
		{
			if (isset($this->_existingData[$tableName]))
			{
				$output += $this->_existingData[$tableName];
			}
		}

		return $output;
	}

	/**
	* Trigger an error with the specified string (or phrase object). Depending on the
	* type of error handler chosen, this may throw an exception.
	*
	* @param string|Phrase $error Error message
	* @param string|false $errorKey Unique key for the error. Used to prevent multiple errors from the same field being displayed.
	* @param boolean $specificError If true and error key specified, overwrites an existing error with this name
	*/
	public function error($error, $errorKey = false, $specificError = true)
	{
		if ($errorKey !== false)
		{
			if ($specificError || !isset($this->_errors[strval($errorKey)]))
			{
				$this->_errors[strval($errorKey)] = $error;
			}
		}
		else
		{
			$this->_errors[] = $error;
		}

		if ($this->_errorHandler == self::ERROR_EXCEPTION)
		{
			throw new PrintableException($error);
		}
	}

	/**
	 * Merges a set of errors into this DW.
	 *
	 * @param array $errors
	 */
	public function mergeErrors(array $errors)
	{
		foreach ($errors AS $errorKey => $error)
		{
			if (is_integer($errorKey))
			{
				$errorKey = false;
			}
			$this->error($error, $errorKey);
		}
	}

	/**
	* Gets all errors that have been triggered. If {@link preSave()} has been
	* called and this returns an empty array, a {@link save()} should go through.
	*
	* @return array
	*/
	public function getErrors()
	{
		return $this->_errors;
	}

	/**
	 * Determines if this DW has errors.
	 *
	 * @return boolean
	 */
	public function hasErrors()
	{
		return (count($this->_errors) > 0);
	}

	/**
	 * Gets the specific named error if reported.
	 *
	 * @param string $errorKey
	 *
	 * @return string
	 */
	public function getError($errorKey)
	{
		return ($this->_errors[$errorKey] ?? false);
	}

	/**
	 * Gets all new data that has been set to date
	 *
	 * @return array
	 */
	public function getNewData()
	{
		return $this->_newData;
	}

	/**
	 * Gets all new data that has been set to date, merged through all tables.
	 *
	 * @param string $tableName
	 */
	public function getMergedNewData($tableName = '')
	{
		$tables = $this->_getTableList($tableName);

		$output = [];

		// loop through all tables and use the first value that comes up for a field.
		// this assumes that the more "primary" tables come first
		foreach ($tables AS $tableName)
		{
			if (isset($this->_newData[$tableName]))
			{
				$output += $this->_newData[$tableName];
			}
		}

		return $output;
	}

	/**
	 * Updates the version ID field for the specified record. Th add-on ID
	 * is determined from a previously set value, so it should not be updated
	 * after this is called.
	 *
	 * @param string $versionIdField Name of the field the version ID will be written to
	 * @param string $versionStringField Name of the field where the version string will be written to
	 * @param string $addOnIdField Name of the field the add-on ID is kept in
	 *
	 * @return integer Version ID to use
	 */
	public function updateVersionId($versionIdField = 'version_id', $versionStringField = 'version_string', $addOnIdField = 'addon_id')
	{
		return 0;
	}

	/**
	* Determines if we have errors that would prevent a save. If the silent error
	* handler is used, errors simply trigger a return of true (yes, we have errors);
	* otherwise, errors trigger an exception. Generally, if errors are to be handled,
	* save shouldn't be called.
	*
	* @return boolean True if there are errors
	*/
	protected function _haveErrorsPreventSave()
	{
		if ($this->_errors)
		{
			if ($this->_errorHandler == self::ERROR_SILENT)
			{
				return true;
			}
			else
			{
				throw new PrintableException($this->_errors);
			}
		}

		return false;
	}

	/**
	* External handler to get the SQL update/delete condition for this data. If there is no
	* existing data, this always returns an empty string, otherwise it proxies
	* to {@link _getUpdateCondition()}, which is an abstract function.
	*
	* @param string $tableName Name of the table to fetch the condition for
	*
	* @return string
	*/
	public function getUpdateCondition($tableName)
	{
		if (!$this->_existingData || !isset($this->_existingData[$tableName]))
		{
			return '';
		}
		else
		{
			return $this->_getUpdateCondition($tableName);
		}
	}

	/**
	* Saves the changes to the data. This either updates an existing record or inserts
	* a new one, depending whether {@link setExistingData} was called.
	*
	* After a successful insert with an auto_increment field, the value will be stored
	* into the new data array with a field marked as autoIncrement. Use {@link get()}
	* to get access to it.
	*
	* @return boolean True on success
	*/
	public function save()
	{
		$this->preSave();

		if ($this->_haveErrorsPreventSave())
		{
			return false;
		}

		if (!$this->_newData)
		{
			// nothing to change; error if insert, act as if everything is ok on update
			if (!$this->_existingData)
			{
				throw new \LogicException('Cannot save item when no data has been set');
			}
		}

		$this->_beginDbTransaction();

		try
		{
			$this->_save();

			if (!$this->_importMode)
			{
				$this->_postSave();
				$this->_performLanguageRebuild();
			}
		}
		catch (\Exception $e)
		{
			$this->_rollbackDbTransaction();
			throw $e;
		}

		$this->_commitDbTransaction();

		if (!$this->_importMode)
		{
			$this->_postSaveAfterTransaction();
		}

		return true;
	}

	/**
	* Public facing handler for final verification before a save. Any verification
	* or complex defaults may be set by this.
	*
	* It is generally not advisable to override this function. If you just want to
	* add pre-save behaviors, override {@link _preSave()}.
	*/
	public function preSave()
	{
		if ($this->_preSaveCalled)
		{
			return;
		}

		$this->_preSaveDefaults();
		if (!$this->_importMode)
		{
			$this->_preSave();

			if (!$this->_existingData)
			{
				$this->_resolveDefaultsAndRequiredFieldsForInsert();
			}
			else
			{
				$this->_checkRequiredFieldsForUpdate();
			}
		}
		else if (!$this->_existingData)
		{
			$this->_resolveDefaultsAndRequiredFieldsForInsert(false);
		}

		$this->_preSaveCalled = true;
	}

	/**
	 * Method designed to be overridden by child classes to add pre-save behaviors that
	 * set dynamic defaults. This is still called in import mode.
	 */
	protected function _preSaveDefaults()
	{
	}

	/**
	* Method designed to be overridden by child classes to add pre-save behaviors. This
	* is not callbed in import mode.
	*/
	protected function _preSave()
	{
	}

	/**
	* This resolves unset fields to their defaults (if available) and the checks
	* for required fields that are unset or empty. If a required field is not
	* set properly, an error is thrown.
	*
	* @param boolean $checkRequired If true, checks required fields
	*/
	protected function _resolveDefaultsAndRequiredFieldsForInsert($checkRequired = true)
	{
		foreach ($this->_fields AS $tableName => $fields)
		{
			foreach ($fields AS $field => $fieldData)
			{
				// when default is an array it references another column in an earlier table
				if ((!isset($this->_newData[$tableName]) || !array_key_exists($field, $this->_newData[$tableName]))
					&& array_key_exists('default', $fieldData)
					&& !is_array($fieldData['default'])
				)
				{
					$this->_setInternal($tableName, $field, $fieldData['default']);
				}

				if ($checkRequired
					&& !empty($fieldData['required'])
					&& (
						!isset($this->_newData[$tableName])
						|| !array_key_exists($field, $this->_newData[$tableName])
						|| $this->_newData[$tableName][$field] === ''
					)
				)
				{
					// references an externalID, we can't resolve this
					if (isset($fieldData['default']) && is_array($fieldData['default']))
					{
						continue;
					}
					$this->_triggerRequiredFieldError($tableName, $field);
				}
			}
		}
	}

	/**
	 * Checks that required field values are still maintained on updates.
	 */
	protected function _checkRequiredFieldsForUpdate()
	{
		foreach ($this->_fields AS $tableName => $fields)
		{
			foreach ($fields AS $field => $fieldData)
			{
				if (!isset($this->_newData[$tableName]) || !array_key_exists($field, $this->_newData[$tableName]))
				{
					continue;
				}

				if (!empty($fieldData['required']) && $this->_newData[$tableName][$field] === '')
				{
					$this->_triggerRequiredFieldError($tableName, $field);
				}
			}
		}
	}

	/**
	 * Triggers the error for a required field not being specified.
	 *
	 * @param string $tableName
	 * @param string $field
	 */
	protected function _triggerRequiredFieldError($tableName, $field)
	{
		$errorText = $this->_getSpecificRequiredFieldErrorText($tableName, $field);
		if ($errorText)
		{
			$this->error($errorText, $field, false);
		}
		else if (isset($this->_fields[$tableName][$field]['required']) && is_string($this->_fields[$tableName][$field]['required']))
		{
			$this->error(\XF::phrase($this->_fields[$tableName][$field]['required']), $field, false);
		}
		else
		{

			$this->error(\XF::phrase('please_enter_value_for_required_field_x', ['field' => $field]), $field, false);
		}
	}

	/**
	 * Gets the error text (or phrase) for a specific required field. Concrete DWs
	 * may override this to get nicer error messages for specific fields.
	 *
	 * @param string $tableName
	 * @param string $field
	 *
	 * @return false|string|Phrase
	 */
	protected function _getSpecificRequiredFieldErrorText($tableName, $field)
	{
		return false;
	}

	/**
	 * Returns true if this DW is updating a record, rather than inserting one.
	 *
	 * @return boolean
	 */
	public function isUpdate()
	{
		return !empty($this->_existingData);
	}

	/**
	 * Returns true if this DW is inserting a record, rather than updating one.
	 *
	 * @return boolean
	 */
	public function isInsert()
	{
		return !$this->isUpdate();
	}

	/**
	* Internal save handler. Deals with both updates and inserts.
	*/
	protected function _save()
	{
		if ($this->isUpdate())
		{
			$this->_update();
		}
		else
		{
			$this->_insert();
		}
	}

	/**
	* Internal save handler.
	*/
	protected function _insert()
	{
		foreach ($this->_getTableList() AS $tableName)
		{
			$this->_db->insert($tableName, $this->_newData[$tableName]);
			$this->_setAutoIncrementValue($this->_db->lastInsertId(), $tableName, true);
		}
	}

	/**
	* Internal update handler.
	*/
	protected function _update()
	{
		foreach ($this->_getTableList() AS $tableName)
		{
			if (!($update = $this->getUpdateCondition($tableName)) || empty($this->_newData[$tableName]))
			{
				continue;
			}
			$this->_db->update($tableName, $this->_newData[$tableName], $update);
		}
	}

	/**
	* Sets the auto-increment value to the auto increment field, if there is one.
	* If the ID passed in is 0, nothing will be updated.
	*
	* @param integer $insertId Auto-increment value from 0.
	* @param string $tableName Name of the table set the auto increment field in
	* @param bool $updateAll Update all tables with cross referenced auto increment fields
	*
	* @return boolean True on update
	*/
	protected function _setAutoIncrementValue($insertId, $tableName, $updateAll = false)
	{
		if (!$insertId)
		{
			return false;
		}

		$field = $this->_getAutoIncrementField($tableName);
		if (!$field)
		{
			return false;
		}

		$insertId += 0;

		$this->_newData[$tableName][$field] = $insertId;
		if ($updateAll)
		{
			foreach ($this->_fields AS $table => $fieldData)
			{
				foreach ($fieldData AS $fieldType)
				{
					if (!isset($fieldType['default']) || !is_array($fieldType['default']))
					{
						continue;
					}

					if ($fieldType['default'][0] == $tableName && !$this->get($field, $table))
					{
						$this->_newData[$table][$field] = $insertId;
					}
				}
			}
		}
		return true;
	}

	/**
	* Finds the auto-increment field in the list of fields. This field is simply
	* the first field tagged with the autoIncrement flag.
	*
	* @param string $tableName Name of the table to obtain the field for
	*
	* @return string|false Name of the field if found or false
	*/
	protected function _getAutoIncrementField($tableName)
	{
		foreach ($this->_fields[$tableName] AS $field => $fieldData)
		{
			if (!empty($fieldData['autoIncrement']))
			{
				return $field;
			}
		}

		return false;
	}

	/**
	* Finds the first table in the _fields array
	*
	* @return string|false Name of the table or false
	*/
	protected function _getPrimaryTable()
	{
		$tables = array_keys($this->_fields);
		return ($tables[0] ?? false);
	}

	/**
	 * Gets an array with a list of all table names or, if provided, only
	 * the specified table.
	 *
	 * @param string $tableName Optional table to limit results to.
	 *
	 * @return array
	 */
	protected function _getTableList($tableName = '')
	{
		return ($tableName ? [$tableName] : array_keys($this->_fields));
	}

	/**
	 * @return array
	 */
	public function getTables()
	{
		return array_keys($this->_fields);
	}

	/**
	* Method designed to be overridden by child classes to add post-save behaviors.
	* This is not called in import mode.
	*/
	protected function _postSave()
	{
	}

	/**
	 * Method designed to be overridden by child classes to add post-save behaviors
	 * that should be run after the transaction is committed. This is not called in
	 * import mode.
	 */
	protected function _postSaveAfterTransaction()
	{

	}

	/**
	* Deletes the record that was selected by a call to {@link setExistingData()}.
	*
	* @return boolean True on success
	*/
	public function delete()
	{
		$this->preDelete();

		if ($this->_haveErrorsPreventSave())
		{
			return false;
		}

		$this->_beginDbTransaction();

		$this->_delete();
		$this->_postDelete();
		$this->_performLanguageRebuild();

		$this->_commitDbTransaction();

		return true;
	}

	/**
	* Public facing handler for final verification before a delete. Any verification
	* or complex defaults may be set by this.
	*
	* It is generally not advisable to override this function. If you just want to
	* add pre-delete behaviors, override {@link _preDelete()}.
	*/
	public function preDelete()
	{
		if ($this->_preDeleteCalled)
		{
			return;
		}

		$this->_preDelete();

		$this->_preDeleteCalled = true;
	}

	/**
	* Method designed to be overridden by child classes to add pre-delete behaviors.
	*/
	protected function _preDelete()
	{
	}

	/**
	* Internal handler for a delete action. Actually does the delete.
	*/
	protected function _delete()
	{
		foreach ($this->_getTableList() AS $tableName)
		{
			$condition = $this->getUpdateCondition($tableName);
			if (!$condition)
			{
				throw new \LogicException('Cannot delete data without a condition');
			}
			$this->_db->delete($tableName, $condition);
		}
	}

	/**
	* Method designed to be overridden by child classes to add pre-delete behaviors.
	*/
	protected function _postDelete()
	{
	}

	/**
	 * Inserts or updates a master (language 0) phrase. Errors will be silently ignored.
	 *
	 * @param string $title
	 * @param string $text
	 * @param string $addOnId
	 * @param array $extra
	 */
	protected function _insertOrUpdateMasterPhrase($title, $text, $addOnId = '', array $extra = [])
	{
		$this->_getPhraseModel()->insertOrUpdateMasterPhrase($title, $text, $addOnId, $extra, [
			XenForo_DataWriter_Phrase::OPTION_REBUILD_LANGUAGE_CACHE => false,
			XenForo_DataWriter_Phrase::OPTION_RECOMPILE_TEMPLATE => false,
		]);
		$this->_triggerLanguageRebuild = true;
	}

	/**
	 * Deletes the named master phrase if it exists.
	 *
	 * @param string $title
	 */
	protected function _deleteMasterPhrase($title)
	{
		$this->_getPhraseModel()->deleteMasterPhrase($title, [
			XenForo_DataWriter_Phrase::OPTION_REBUILD_LANGUAGE_CACHE => false,
			XenForo_DataWriter_Phrase::OPTION_RECOMPILE_TEMPLATE => false,
		]);
		$this->_triggerLanguageRebuild = true;
	}

	/**
	 * Renames a master phrase. If you get a conflict, it will
	 * be silently ignored.
	 *
	 * @param string $oldName
	 * @param string $newName
	 */
	protected function _renameMasterPhrase($oldName, $newName)
	{
		$this->_getPhraseModel()->renameMasterPhrase($oldName, $newName, [
			//XenForo_DataWriter_Phrase::OPTION_REBUILD_LANGUAGE_CACHE => false,
			XenForo_DataWriter_Phrase::OPTION_RECOMPILE_TEMPLATE => false,
		]);
		$this->_triggerLanguageRebuild = true;
	}

	/**
	 * Changes the add-on for a phrase. This should be called when you have phrases
	 * that are attached to a DW entity that itself is attached to an add-on.
	 *
	 * @param string $phraseName
	 * @param string $addOnId
	 */
	protected function _changePhraseAddOn($phraseName, $addOnId)
	{
		$this->_getPhraseModel()->changePhraseAddOn($phraseName, $addOnId);
	}

	/**
	* Starts a new database transaction.
	*/
	protected function _beginDbTransaction()
	{
		$this->_db->beginTransaction();
		return true;
	}

	/**
	* Commits a new database transaction.
	*/
	protected function _commitDbTransaction()
	{
		$this->_db->commit();
		return true;
	}

	/**
	* Rolls a database transaction back.
	*/
	protected function _rollbackDbTransaction()
	{
		$this->_db->rollback();
		return true;
	}

	/**
	 * Performs a language rebuild if needed.
	 */
	protected function _performLanguageRebuild()
	{
	}

	/**
	 * Gets the specified model object from the cache. If it does not exist,
	 * it will be instantiated.
	 *
	 * @param string $class Name of the class to load
	 *
	 * @return Model
	 */
	public function getModelFromCache($class)
	{
		if (!isset(self::$_modelCache[$class]))
		{
			self::$_modelCache[$class] = Model::create($class);
		}

		return self::$_modelCache[$class];
	}

	/**
	 * Returns the user model
	 *
	 * @return User
	 */
	protected function _getUserModel()
	{
		return $this->getModelFromCache('XenForo_Model_User');
	}

	/**
	 * @return UserIgnore
	 */
	protected function _getUserIgnoreModel()
	{
		return $this->getModelFromCache('XenForo_Model_UserIgnore');
	}

	/**
	 * Returns the phrase model
	 *
	 * @return Model\Phrase
	 */
	protected function _getPhraseModel()
	{
		return $this->getModelFromCache('XenForo_Model_Phrase');
	}

	/**
	 * Returns the news feed model
	 *
	 * @return NewsFeed
	 */
	protected function _getNewsFeedModel()
	{
		return $this->getModelFromCache('XenForo_Model_NewsFeed');
	}

	/**
	 * Returns the alert model
	 *
	 * @return Alert
	 */
	protected function _getAlertModel()
	{
		return $this->getModelFromCache('XenForo_Model_Alert');
	}

	/**
	 * Returns the admin search model
	 *
	 * @return AdminSearch
	 */
	protected function _getAdminSearchModel()
	{
		return $this->getModelFromCache('XenForo_Model_AdminSearch');
	}

	/**
	* Factory method to get the named data writer. The class must exist or be autoloadable
	* or an exception will be thrown.
	*
	* @param string     $class Class to load
	* @param mixed      $errorHandler Error handler. See {@link ERROR_EXCEPTION} and related.
	* @param array|null $inject Dependencies to inject. See {@link __construct()}.
	*
	* @throws \InvalidArgumentException
	*
	* @return DataWriter
	*/
	public static function create($class, $errorHandler = self::ERROR_ARRAY, ?array $inject = null)
	{
		if (strpos($class, ':'))
		{
			$createClass = \XF::stringToClass($class, '%s\Legacy\DataWriter\%s');
		}
		else if (strpos($class, '\\'))
		{
			$createClass = $class;
		}
		else
		{
			$createClass = $class;
			$createClass = preg_replace('/^XenForo_/', 'XF\\', $createClass);
			$createClass = preg_replace('/(_|\\\\)DataWriter_/', '\\Legacy\\DataWriter\\', $createClass);
			$createClass = str_replace('_', '\\', $createClass);
		}

		return new $createClass($errorHandler, $inject);
	}
}
