<?php

/**
 * This file is part of the CodeIgniter 4 framework.
 *
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace CodeIgniter;

use CodeIgniter\Exceptions\CastException;
use CodeIgniter\I18n\Time;
use DateTime;
use Exception;
use JsonSerializable;
use ReflectionException;
use stdClass;

/**
 * Entity encapsulation, for use with CodeIgniter\Model
 */
class Entity implements JsonSerializable
{
	/**
	 * Maps names used in sets and gets against unique
	 * names within the class, allowing independence from
	 * database column names.
	 *
	 * Example:
	 *  $datamap = [
	 *      'db_name' => 'class_name'
	 *  ];
	 */
	protected $datamap = [];

	protected $dates = [
		'created_at',
		'updated_at',
		'deleted_at',
	];

	/**
	 * Array of field names and the type of value to cast them as
	 * when they are accessed.
	 */
	protected $casts = [];

	/**
	 * Holds the current values of all class vars.
	 *
	 * @var array
	 */
	protected $attributes = [];

	/**
	 * Holds original copies of all class vars so
	 * we can determine what's actually been changed
	 * and not accidentally write nulls where we shouldn't.
	 *
	 * @var array
	 */
	protected $original = [];

	/**
	 * Holds info whenever properties have to be casted
	 *
	 * @var boolean
	 **/
	private $_cast = true;

	/**
	 * Allows filling in Entity parameters during construction.
	 *
	 * @param array|null $data
	 */
	public function __construct(array $data = null)
	{
		$this->syncOriginal();

		$this->fill($data);
	}

	/**
	 * Takes an array of key/value pairs and sets them as
	 * class properties, using any `setCamelCasedProperty()` methods
	 * that may or may not exist.
	 *
	 * @param array $data
	 *
	 * @return $this
	 */
	public function fill(array $data = null)
	{
		if (! is_array($data))
		{
			return $this;
		}

		foreach ($data as $key => $value)
		{
			$this->__set($key, $value);
		}

		return $this;
	}

	//--------------------------------------------------------------------

	/**
	 * General method that will return all public and protected
	 * values of this entity as an array. All values are accessed
	 * through the __get() magic method so will have any casts, etc
	 * applied to them.
	 *
	 * @param boolean $onlyChanged If true, only return values that have changed since object creation
	 * @param boolean $cast        If true, properties will be casted.
	 * @param boolean $recursive   If true, inner entities will be casted as array as well.
	 *
	 * @return array
	 * @throws Exception
	 */
	public function toArray(bool $onlyChanged = false, bool $cast = true, bool $recursive = false): array
	{
		$this->_cast = $cast;
		$return      = [];

		$keys = array_keys($this->attributes);
		$keys = array_filter($keys, function ($key) {
			return strpos($key, '_') !== 0;
		});

		if (is_array($this->datamap))
		{
			$keys = array_diff($keys, $this->datamap);
			$keys = array_unique(array_merge($keys, array_keys($this->datamap)));
		}

		// we need to loop over our properties so that we
		// allow our magic methods a chance to do their thing.
		foreach ($keys as $key)
		{
			if ($onlyChanged && ! $this->hasChanged($key))
			{
				continue;
			}

			$return[$key] = $this->__get($key);

			if ($recursive)
			{
				if ($return[$key] instanceof Entity)
				{
					$return[$key] = $return[$key]->toArray($onlyChanged, $cast, $recursive);
				}
				elseif (is_callable([$return[$key], 'toArray']))
				{
					$return[$key] = $return[$key]->toArray();
				}
			}
		}

		$this->_cast = true;
		return $return;
	}

	//--------------------------------------------------------------------

	/**
	 * Returns the raw values of the current attributes.
	 *
	 * @param boolean $onlyChanged If true, only return values that have changed since object creation
	 * @param boolean $recursive   If true, inner entities will be casted as array as well.
	 *
	 * @return array
	 */
	public function toRawArray(bool $onlyChanged = false, bool $recursive = false): array
	{
		$return = [];

		if (! $onlyChanged)
		{
			if ($recursive)
			{
				return array_map(function ($value) use ($onlyChanged, $recursive) {
					if ($value instanceof Entity)
					{
						$value = $value->toRawArray($onlyChanged, $recursive);
					}
					elseif (is_callable([$value, 'toRawArray']))
					{
						$value = $value->toRawArray();
					}
					return $value;
				}, $this->attributes);
			}

			return $this->attributes;
		}

		foreach ($this->attributes as $key => $value)
		{
			if (! $this->hasChanged($key))
			{
				continue;
			}

			if ($recursive)
			{
				if ($value instanceof Entity)
				{
					$value = $value->toRawArray($onlyChanged, $recursive);
				}
				elseif (is_callable([$value, 'toRawArray']))
				{
					$value = $value->toRawArray();
				}
			}

			$return[$key] = $value;
		}

		return $return;
	}

	//--------------------------------------------------------------------

	/**
	 * Ensures our "original" values match the current values.
	 *
	 * @return $this
	 */
	public function syncOriginal()
	{
		$this->original = $this->attributes;

		return $this;
	}

	/**
	 * Checks a property to see if it has changed since the entity was created.
	 * Or, without a parameter, checks if any properties have changed.
	 *
	 * @param string $key
	 *
	 * @return boolean
	 */
	public function hasChanged(string $key = null): bool
	{
		// If no parameter was given then check all attributes
		if ($key === null)
		{
			return     $this->original !== $this->attributes;
		}

		// Key doesn't exist in either
		if (! array_key_exists($key, $this->original) && ! array_key_exists($key, $this->attributes))
		{
			return false;
		}

		// It's a new element
		if (! array_key_exists($key, $this->original) && array_key_exists($key, $this->attributes))
		{
			return true;
		}

		return $this->original[$key] !== $this->attributes[$key];
	}

	/**
	 * Magic method to allow retrieval of protected and private
	 * class properties either by their name, or through a `getCamelCasedProperty()`
	 * method.
	 *
	 * Examples:
	 *
	 *      $p = $this->my_property
	 *      $p = $this->getMyProperty()
	 *
	 * @param string $key
	 *
	 * @return mixed
	 * @throws Exception
	 */
	public function __get(string $key)
	{
		$key    = $this->mapProperty($key);
		$result = null;

		// Convert to CamelCase for the method
		$method = 'get' . str_replace(' ', '', ucwords(str_replace(['-', '_'], ' ', $key)));

		// if a set* method exists for this key,
		// use that method to insert this value.
		if (method_exists($this, $method))
		{
			$result = $this->$method();
		}

		// Otherwise return the protected property
		// if it exists.
		elseif (array_key_exists($key, $this->attributes))
		{
			$result = $this->attributes[$key];
		}

		// Do we need to mutate this into a date?
		if (in_array($key, $this->dates, true))
		{
			$result = $this->mutateDate($result);
		}
		// Or cast it as something?
		elseif ($this->_cast && ! empty($this->casts[$key]))
		{
			$result = $this->castAs($result, $this->casts[$key]);
		}

		return $result;
	}

	//--------------------------------------------------------------------

	/**
	 * Magic method to all protected/private class properties to be easily set,
	 * either through a direct access or a `setCamelCasedProperty()` method.
	 *
	 * Examples:
	 *
	 *      $this->my_property = $p;
	 *      $this->setMyProperty() = $p;
	 *
	 * @param string     $key
	 * @param mixed|null $value
	 *
	 * @return $this
	 * @throws Exception
	 */
	public function __set(string $key, $value = null)
	{
		$key = $this->mapProperty($key);

		// Check if the field should be mutated into a date
		if (in_array($key, $this->dates, true))
		{
			$value = $this->mutateDate($value);
		}

		$isNullable = false;
		$castTo     = false;

		if (array_key_exists($key, $this->casts))
		{
			$isNullable = strpos($this->casts[$key], '?') === 0;
			$castTo     = $isNullable ? substr($this->casts[$key], 1) : $this->casts[$key];
		}

		if (! $isNullable || ! is_null($value))
		{
			// CSV casts need to be imploded.
			if ($castTo === 'csv')
			{
				$value = implode(',', $value);
			}

			// Array casting requires that we serialize the value
			// when setting it so that it can easily be stored
			// back to the database.
			if ($castTo === 'array')
			{
				$value = serialize($value);
			}

			// JSON casting requires that we JSONize the value
			// when setting it so that it can easily be stored
			// back to the database.
			if (($castTo === 'json' || $castTo === 'json-array') && function_exists('json_encode'))
			{
				$value = json_encode($value, JSON_UNESCAPED_UNICODE);

				if (json_last_error() !== JSON_ERROR_NONE)
				{
					throw CastException::forInvalidJsonFormatException(json_last_error());
				}
			}
		}

		// if a set* method exists for this key,
		// use that method to insert this value.
		// *) should be outside $isNullable check - SO maybe wants to do sth with null value automatically
		$method = 'set' . str_replace(' ', '', ucwords(str_replace(['-', '_'], ' ', $key)));
		if (method_exists($this, $method))
		{
			$this->$method($value);

			return $this;
		}

		// Otherwise, just the value.
		// This allows for creation of new class
		// properties that are undefined, though
		// they cannot be saved. Useful for
		// grabbing values through joins,
		// assigning relationships, etc.
		$this->attributes[$key] = $value;

		return $this;
	}

	//--------------------------------------------------------------------

	/**
	 * Unsets an attribute property.
	 *
	 * @param string $key
	 *
	 * @throws ReflectionException
	 */
	public function __unset(string $key)
	{
		unset($this->attributes[$key]);
	}

	//--------------------------------------------------------------------

	/**
	 * Returns true if a property exists names $key, or a getter method
	 * exists named like for __get().
	 *
	 * @param string $key
	 *
	 * @return boolean
	 */
	public function __isset(string $key): bool
	{
		$key = $this->mapProperty($key);

		$method = 'get' . str_replace(' ', '', ucwords(str_replace(['-', '_'], ' ', $key)));

		if (method_exists($this, $method))
		{
			return true;
		}

		return isset($this->attributes[$key]);
	}

	/**
	 * Set raw data array without any mutations
	 *
	 * @param  array $data
	 * @return $this
	 */
	public function setAttributes(array $data)
	{
		$this->attributes = $data;
		$this->syncOriginal();
		return $this;
	}

	//--------------------------------------------------------------------

	/**
	 * Checks the datamap to see if this column name is being mapped,
	 * and returns the mapped name, if any, or the original name.
	 *
	 * @param string $key
	 *
	 * @return mixed|string
	 */
	protected function mapProperty(string $key)
	{
		if (empty($this->datamap))
		{
			return $key;
		}

		if (! empty($this->datamap[$key]))
		{
			return $this->datamap[$key];
		}

		return $key;
	}

	//--------------------------------------------------------------------

	/**
	 * Converts the given string|timestamp|DateTime|Time instance
	 * into a \CodeIgniter\I18n\Time object.
	 *
	 * @param mixed $value
	 *
	 * @return Time|mixed
	 * @throws Exception
	 */
	protected function mutateDate($value)
	{
		if ($value instanceof Time)
		{
			return $value;
		}

		if ($value instanceof DateTime)
		{
			return Time::instance($value);
		}

		if (is_numeric($value))
		{
			return Time::createFromTimestamp($value);
		}

		if (is_string($value))
		{
			return Time::parse($value);
		}

		return $value;
	}

	//--------------------------------------------------------------------

	/**
	 * Provides the ability to cast an item as a specific data type.
	 * Add ? at the beginning of $type  (i.e. ?string) to get NULL instead of casting $value if $value === null
	 *
	 * @param mixed  $value
	 * @param string $type
	 *
	 * @return mixed
	 * @throws Exception
	 */
	protected function castAs($value, string $type)
	{
		if (strpos($type, '?') === 0)
		{
			if ($value === null)
			{
				return null;
			}
			$type = substr($type, 1);
		}

		switch($type)
		{
			case 'int':
			case 'integer': // alias for 'integer'
				$value = (int) $value;
				break;
			case 'float':
				$value = (float) $value;
				break;
			case 'double':
				$value = (double) $value;
				break;
			case 'string':
				$value = (string) $value;
				break;
			case 'bool':
			case 'boolean': // alias for 'boolean'
				$value = (bool) $value;
				break;
			case 'csv':
				$value = explode(',', $value);
				break;
			case 'object':
				$value = (object) $value;
				break;
			case 'array':
				if (is_string($value) && (strpos($value, 'a:') === 0 || strpos($value, 's:') === 0))
				{
					$value = unserialize($value);
				}

				$value = (array) $value;
				break;
			case 'json':
				$value = $this->castAsJson($value);
				break;
			case 'json-array':
				$value = $this->castAsJson($value, true);
				break;
			case 'datetime':
				return $this->mutateDate($value);
			case 'timestamp':
				return strtotime($value);
		}

		return $value;
	}

	//--------------------------------------------------------------------

	/**
	 * Cast as JSON
	 *
	 * @param mixed   $value
	 * @param boolean $asArray
	 *
	 * @return mixed
	 * @throws CastException
	 */
	private function castAsJson($value, bool $asArray = false)
	{
		$tmp = ! is_null($value) ? ($asArray ? [] : new stdClass) : null;
		if (function_exists('json_decode'))
		{
			if ((is_string($value) && strlen($value) > 1 && in_array($value[0], ['[', '{', '"'], true)) || is_numeric($value))
			{
				$tmp = json_decode($value, $asArray);

				if (json_last_error() !== JSON_ERROR_NONE)
				{
					throw CastException::forInvalidJsonFormatException(json_last_error());
				}
			}
		}
		return $tmp;
	}

	/**
	 * Support for json_encode()
	 *
	 * @return array|mixed
	 * @throws Exception
	 */
	public function jsonSerialize()
	{
		return $this->toArray();
	}

	/**
	 * Change the value of the private $_cast property
	 *
	 * @param  boolean|null $cast
	 * @return boolean|Entity
	 */
	public function cast(bool $cast = null)
	{
		if (null === $cast)
		{
			return $this->_cast;
		}
		$this->_cast = $cast;
		return $this;
	}
}
