<?php

namespace XF\Legacy;

use XF\Db\AbstractAdapter;

use function intval, is_array;

abstract class Model
{
	/**
	 * @var AbstractAdapter
	 */
	protected $_db = null;

	/**
	* Controls whether a cached read is allowed. If not, it should be retrieved
	* from the source.
	*
	* @var boolean
	*/
	protected $_allowCachedRead = true;

	/**
	 * Stores local, instance-specific cached data for each model. This data
	 * is generally treated as canonical, even if {$_allowCachedRead} is false.
	 *
	 * @var array
	 */
	protected $_localCacheData = [];

	/**
	* Constructor. Use {@link create()} statically unless you know what you're doing.
	*/
	public function __construct()
	{
	}

	/**
	 * Injects a local cache value. This should only be used if you know what you're
	 * doing or for testing purposes!
	 *
	 * Note that you cannot get the existing data via the public interface. If you think
	 * you need the set data, use a new object. It defaults to empty. :)
	 *
	 * @param string $name
	 * @param $value
	 */
	public function setLocalCacheData($name, $value)
	{
		$this->_localCacheData[$name] = $value;
	}

	/**
	 * Reset an entry or the entire local cache. This can be used if you know when
	 * some cached data has expired.
	 *
	 * @param string|null $entry If null, resets the whole cache; otherwise, the specified entry
	 */
	public function resetLocalCacheData($name = null)
	{
		if ($name === null)
		{
			$this->_localCacheData = [];
		}
		else if (isset($this->_localCacheData[$name]))
		{
			unset($this->_localCacheData[$name]);
		}
	}

	/**
	 * Gets the named entry from the local cache.
	 *
	 * @param string $name
	 *
	 * @return mixed
	 */
	protected function _getLocalCacheData($name)
	{
		return $this->_localCacheData[$name] ?? false;
	}

	/**
	 * 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)
	{
		return self::create($class);
	}

	/**
	 * Helper method to get the database object.
	 *
	 * @return AbstractAdapter
	 */
	protected function _getDb()
	{
		if ($this->_db === null)
		{
			$this->_db = \XF::db();
		}

		return $this->_db;
	}

	/**
	* Sets whether we're allowed to read values from the cache on a model-level.
	* This may be controllable on an individual level basis, if the implementation
	* allows it.
	*
	* @param boolean
	*/
	public function setAllowCachedRead($allowCachedRead)
	{
		$this->_allowCachedRead = (bool) $allowCachedRead;
	}

	/**
	* Factory method to get the named model. The class must exist or be autoloadable
	* or an exception will be thrown.
	*
	* @param string $class Class to load
	*
	* @return Model
	*/
	public static function create($class)
	{
		if (strpos($class, ':'))
		{
			$createClass = \XF::stringToClass($class, '%s\Legacy\Model\%s');
		}
		else if (strpos($class, '\\'))
		{
			$createClass = $class;
		}
		else
		{
			$createClass = $class;
			$createClass = preg_replace('/^XenForo_/', 'XF\\', $createClass);
			$createClass = preg_replace('/(_|\\\\)Model_/', '\\Legacy\\Model\\', $createClass);
			$createClass = str_replace('_', '\\', $createClass);
		}

		return new $createClass();
	}

	/**
	 * Fetches results from the database with each row keyed according to preference.
	 * The 'key' parameter provides the column name with which to key the result.
	 * For example, calling fetchAllKeyed('SELECT item_id, title, date FROM table', 'item_id')
	 * would result in an array keyed by item_id:
	 * [$itemId] => array('item_id' => $itemId, 'title' => $title, 'date' => $date)
	 *
	 * Note that the specified key must exist in the query result, or it will be ignored.
	 *
	 * @param string SQL to execute
	 * @param string Column with which to key the results array
	 * @param mixed Parameters for the SQL
	 *
	 * @return array
	 */
	public function fetchAllKeyed($sql, $key, $bind = [])
	{
		return $this->_getDb()->fetchAllKeyed($sql, $key, $bind);
	}

	/**
	 * Applies a limit clause to the provided query if a limit value is specified.
	 * If the limit value is 0 or less, no clause is applied.
	 *
	 * @param string $query SQL query to run
	 * @param integer $limit Number of records to limit to; ignored if <= 0
	 * @param integer $offset Offset from the start of the records. 0+
	 *
	 * @return string Query with limit applied if necessary
	 */
	public function limitQueryResults($query, $limit, $offset = 0)
	{
		if ($limit > 0)
		{
			if ($offset < 0)
			{
				$offset = 0;
			}
			return $this->_getDb()->limit($query, $limit, $offset);
		}
		else
		{
			return $query;
		}
	}

	/**
	 * Adds a join to the set of fetch options. Join should be one of the constants.
	 *
	 * @param array $fetchOptions
	 * @param integer $join
	 */
	public function addFetchOptionJoin(array &$fetchOptions, $join)
	{
		if (isset($fetchOptions['join']))
		{
			$fetchOptions['join'] |= $join;
		}
		else
		{
			$fetchOptions['join'] = $join;
		}
	}

	/**
	 * Gets a list of SQL conditions in the format for a clause. This always returns
	 * a value that can be used in a clause such as WHERE.
	 *
	 * @param array $sqlConditions
	 *
	 * @return string
	 */
	public function getConditionsForClause(array $sqlConditions)
	{
		if ($sqlConditions)
		{
			return '(' . implode(') AND (', $sqlConditions) . ')';
		}
		else
		{
			return '1=1';
		}
	}

	/**
	 * Gets the order by clause for an SQL query.
	 *
	 * @param array $choices
	 * @param array $fetchOptions
	 * @param string $defaultOrderSql
	 *
	 * @return string Order by clause or empty string
	 */
	public function getOrderByClause(array $choices, array $fetchOptions, $defaultOrderSql = '')
	{
		$orderSql = null;

		if (!empty($fetchOptions['order']) && isset($choices[$fetchOptions['order']]))
		{
			$orderSql = $choices[$fetchOptions['order']];

			if (empty($fetchOptions['direction']))
			{
				$fetchOptions['direction'] = 'asc';
			}

			$dir = (strtolower($fetchOptions['direction']) == 'desc' ? 'DESC' : 'ASC');
			$orderSqlOld = $orderSql;
			$orderSql = sprintf($orderSql, $dir);
			if ($orderSql === $orderSqlOld)
			{
				$orderSql .= ' ' . $dir;
			}
		}

		if (!$orderSql)
		{
			$orderSql = $defaultOrderSql;
		}
		return ($orderSql ? 'ORDER BY ' . $orderSql : '');
	}

	/**
	 * Adds the equivalent of a limit clause using position-based limits.
	 * It no limit value is specified, nothing will be returned.
	 *
	 * This must be added within a WHERE clause. If a clause is required,
	 * it will begin with "AND", so ensure there is a condition before it.
	 *
	 * @param string $table Name of the table alias to prefix. May be blank for no table reference.
	 * @param integer $limit Number of records to limit to; ignored if <= 0
	 * @param integer $offset Offset from the start of the records. 0+
	 * @param string $column Name of the column that is storing the position
	 *
	 * @return string Position limit clause if needed
	 */
	public function addPositionLimit($table, $limit, $offset = 0, $column = 'position')
	{
		if ($limit > 0)
		{
			$columnRef = ($table ? "$table.$column" : $column);

			return " AND ($columnRef >= " . intval($offset) . " AND $columnRef < " . intval($offset + $limit) . ') ';
		}
		else
		{
			return '';
		}
	}

	/**
	 * Prepares the limit-related fetching options that can be applied to various queries.
	 * Includes: limit, offset, page, and perPage.
	 *
	 * @param array $fetchOptions Unprepared options
	 *
	 * @return array Limit options; keys: limit, offset
	 */
	public function prepareLimitFetchOptions(array $fetchOptions)
	{
		$limitOptions = ['limit' => 0, 'offset' => 0];
		if (isset($fetchOptions['limit']))
		{
			$limitOptions['limit'] = intval($fetchOptions['limit']);
		}
		if (isset($fetchOptions['offset']))
		{
			$limitOptions['offset'] = intval($fetchOptions['offset']);
		}

		if (isset($fetchOptions['perPage']) && $fetchOptions['perPage'] > 0)
		{
			$limitOptions['limit'] = intval($fetchOptions['perPage']);
		}

		if (isset($fetchOptions['page']))
		{
			$page = intval($fetchOptions['page']);
			if ($page < 1)
			{
				$page = 1;
			}

			$limitOptions['offset'] = intval(($page - 1) * $limitOptions['limit']);
		}

		return $limitOptions;
	}

	/**
	 * Prepares state related fetch limits, based on the list of conditions.
	 * Looks for keys "deleted" and "moderated".
	 *
	 * @param array $fetchOptions
	 * @param string $table Name of the table to prefix the state and user fields with
	 * @param string $stateField Name of the field that holds the state
	 * @param string $userField Name of the field that holds the user ID
	 *
	 * @return string SQL condition to limit state
	 */
	public function prepareStateLimitFromConditions(array $fetchOptions, $table = '', $stateField = 'message_state', $userField = 'user_id')
	{
		$fetchOptions = array_merge(
			[
				'deleted' => false,
				'moderated' => false,
			],
			$fetchOptions
		);

		$stateRef = ($table ? "$table.$stateField" : $stateField);
		$userRef = ($table ? "$table.$userField" : $userField);

		$states = ["'visible'"];
		$moderatedLimit = '';

		if ($fetchOptions['deleted'])
		{
			$states[] = "'deleted'";
		}

		if ($fetchOptions['moderated'])
		{
			if ($fetchOptions['moderated'] === true)
			{
				$states[] = "'moderated'";
			}
			else
			{
				$moderatedLimit = " OR ($stateRef = 'moderated' AND $userRef = " . intval($fetchOptions['moderated']) . ')';
			}
		}

		return "$stateRef IN (" . implode(',', $states) . ")$moderatedLimit";
	}

	/**
	 * Standardizes a set of permissions and a user ID to always
	 * have appropriate data. If an invalid permission set or user ID is
	 * provided, the current visitor's will be used.
	 *
	 * @param array|null $permissions Global pPermissions or null to use current visitor's permissions
	 * @param integer|null $userId User permissions belong to or null to use current visitor
	 */
	public function standardizePermissionsAndUserId(&$permissions, &$userId = 0)
	{
		if (!is_array($permissions))
		{
			$permissions = \XF::visitor()->getPermissionSet()->getGlobalPerms();
		}

		if ($userId === null)
		{
			$userId = \XF::visitor()->user_id;
		}
	}

	/**
	 * Standardizes a set of node permissions and a user ID to always
	 * have appropriate data. If an invalid permission set or user ID is
	 * provided, the current visitor's will be used.
	 *
	 * @param integer $nodeId Node permissions are for
	 * @param array|null $permissions Permissions for node or null to use current visitor's permissions
	 * @param integer|null $userId User permissions belong to or null to use current visitor
	 */
	public function standardizeNodePermissionsAndUserId($nodeId, &$permissions, &$userId = 0)
	{
		if (!is_array($permissions))
		{
			$permissions = \XF::visitor()->getPermissionSet()->getContentPerms('node', $nodeId);
		}

		if ($userId === null)
		{
			$userId = \XF::visitor()->user_id;
		}
	}

	/**
	 * Standardizes a permission combination and user ID to always have
	 * appropriate data. If null, users current visitor's values.
	 *
	 * @param integer|null $permissionCombinationId Permission combination ID or null to use current visitor
	 * @param integer|null $userId User permissions belong to or null to use current visitor
	 */
	public function standardizePermissionCombinationIdAndUserId(&$permissionCombinationId, &$userId = 0)
	{
		if ($permissionCombinationId === null)
		{
			$permissionCombinationId = \XF::visitor()->permission_combination_id;
		}

		if ($userId === null)
		{
			$userId = \XF::visitor()->user_id;
		}
	}

	/**
	 * Standardizes a viewing user reference array. This array must contain all basic user info
	 * (preferably all user info) and include global permissions in a "permissions" key.
	 * If not an array or missing a user_id, the visitor's values will be used.
	 *
	 * @param array|null $viewingUser
	 */
	public function standardizeViewingUserReference(?array &$viewingUser = null)
	{
		if (!is_array($viewingUser) || !isset($viewingUser['user_id']))
		{
			$viewingUser = XenForo_Visitor::getInstance()->toArray();
		}

		return $viewingUser;
	}

	/**
	 * Standardizes the viewing user reference for the specific node.
	 *
	 * @param integer $nodeId
	 * @param array|null $viewingUser Viewing user; if null, use visitor
	 * @param array|null $nodePermissions Permissions for this node; if null, use visitor's
	 */
	public function standardizeViewingUserReferenceForNode($nodeId, ?array &$viewingUser = null, ?array &$nodePermissions = null)
	{
		$this->standardizeViewingUserReference($viewingUser);

		if (!is_array($nodePermissions))
		{
			$nodePermissions = \XF::visitor()->getPermissionSet()->getContentPerms('node', $nodeId);
		}
	}

	/**
	 * Helper to unserialize permissions in a list of items.
	 *
	 * @param array $items List of items
	 * @param string $serializedKey Key where serialized permissions are
	 * @param string $targetKey Key where unserialized permissions will go
	 *
	 * @return array List of items with permissions unserialized
	 */
	public function unserializePermissionsInList(array $items, $serializedKey, $targetKey = 'permissions')
	{
		foreach ($items AS &$item)
		{
			$item[$targetKey] = (
				!empty($item[$serializedKey])
				? unserialize($item[$serializedKey])
				: []
			);
		}

		return $items;
	}

	/**
	 * Gets the value of the specified field for each content type that has that field.
	 *
	 * @param string $fieldName
	 *
	 * @return array Format: [content type] => field value
	 */
	public function getContentTypesWithField($fieldName)
	{
		if (XenForo_Application::isRegistered('contentTypes'))
		{
			$contentTypes = XenForo_Application::get('contentTypes');
		}
		else
		{
			$contentTypes = XenForo_Model::create('XenForo_Model_ContentType')->getContentTypesForCache();
			XenForo_Application::set('contentTypes', $contentTypes);
		}

		$output = [];
		foreach ($contentTypes AS $contentType => $fields)
		{
			if (isset($fields[$fieldName]))
			{
				$output[$contentType] = $fields[$fieldName];
			}
		}

		return $output;
	}

	/**
	 * Gets the specified field from a content type, if specified for that type.
	 *
	 * @param string $contentType
	 * @param string $fieldName
	 *
	 * @return string|false
	 */
	public function getContentTypeField($contentType, $fieldName)
	{
		if (XenForo_Application::isRegistered('contentTypes'))
		{
			$contentTypes = XenForo_Application::get('contentTypes');
		}
		else
		{
			$contentTypes = XenForo_Model::create('XenForo_Model_ContentType')->getContentTypesForCache();
			XenForo_Application::set('contentTypes', $contentTypes);
		}

		if (isset($contentTypes[$contentType][$fieldName]))
		{
			return $contentTypes[$contentType][$fieldName];
		}
		else
		{
			return false;
		}
	}

	public function getCutOffCondition($column, $condition)
	{
		[$operator, $cutOff] = $condition;

		$this->assertValidCutOffOperator($operator, true);

		$db = $this->_getDb();

		if ($operator === '>=<')
		{
			[, $cutOffLower, $cutOffHigher] = $condition;

			return "($column >= " . $db->quote($cutOffLower) . " AND $column <= " . $db->quote($cutOffHigher) . ')';
		}
		else
		{
			return "$column $operator " . $db->quote($cutOff);
		}
	}

	/**
	 * Ensures that a valid cut-off operator is passed.
	 *
	 * @param string $operator
	 * @param boolean $allowBetween
	 */
	public function assertValidCutOffOperator($operator, $allowBetween = false)
	{
		switch ($operator)
		{
			case '<':
			case '<=':
			case '=':
			case '>':
			case '>=':
				break;

			case '>=<':
				if ($allowBetween)
				{
					return;
				}
				// no break

			default:
				throw new \InvalidArgumentException('Invalid cut off operator.');
		}
	}
}
