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

namespace Awf\User;
use Awf\Registry\Registry;
use Awf\Utils\HashHelper;

class User implements UserInterface
{
	/** @var  integer  The numeric ID of the user */
	protected $id = null;

	/** @var  string  The username */
	protected $username = '';

	/** @var  string  The full (real) name of the user */
	protected $name = '';

	/** @var  string  The email address of the user */
	protected $email = '';

	/** @var  string  The hashed password of the user */
	protected $password = '';

	/** @var  Registry  The parameters registry */
	protected $parameters = null;

	/** @var  array[PrivilegeInterface]  A hash array of the privilege plugin objects */
	protected $privileges = array();

	/** @var  array[AuthenticationInterface]  A hash array of the privilege plugin objects */
	protected $authentications = array();

	/**
	 * Binds the data to the object
	 *
	 * @param   array|object  $data  The data to bind
	 *
	 * @return  void
	 */
	public function bind(&$data)
	{
		// Reset the parameters storage
		$this->parameters = null;

		// Cast the data to an object, if necessary
		if (!is_object($data))
		{
			$data = (object)$data;
		}

		// Run the privileges' onBeforeLoad events
		if (!empty($this->privileges))
		{
			/** @var  PrivilegeInterface  $privilegeObject */
			foreach ($this->privileges as $name => $privilegeObject)
			{
				$privilegeObject->onBeforeLoad($data);
			}
		}

		// Switch the data back to an array
		$data = (array)$data;

		foreach ($data as $k => $v)
		{
			if (in_array($k, array('privileges')))
			{
				continue;
			}

			if ($k == 'parameters')
			{
				$this->parameters = new Registry($v);
			}
			elseif (property_exists($this, $k))
			{
				$this->$k = $v;
			}
		}

		// Make sure we always have a parameters storage
		if (empty($this->parameters))
		{
			$this->parameters = new Registry();
		}

		// Run the privileges' onAfterLoad events
		if (!empty($this->privileges))
		{
			/** @var  PrivilegeInterface  $privilegeObject */
			foreach ($this->privileges as $name => $privilegeObject)
			{
				$privilegeObject->onAfterLoad();
			}
		}
	}


	/**
	 * Returns the ID of the user
	 *
	 * @return  integer
	 */
	public function getId()
	{
		return $this->id;
	}

	/**
	 * Return the username of this user
	 *
	 * @return  string
	 */
	public function getUsername()
	{
		return $this->username;
	}

	/**
	 * Set the username of this user
	 *
	 * @param   string  $username  The username to set
	 *
	 * @return  void
	 */
	public function setUsername($username)
	{
		$this->username = $username;
	}

	/**
	 * Get the full name of this user
	 *
	 * @return  string
	 */
	public function getName()
	{
		return $this->name;
	}

	/**
	 * Set the name of this user
	 *
	 * @param   string  $name  The full name to set
	 *
	 * @return  void
	 */
	public function setName($name)
	{
		$this->name = $name;
	}

	/**
	 * Returns the email of the user
	 *
	 * @return  string
	 */
	public function getEmail()
	{
		return $this->email;
	}

	/**
	 * Sets the email of the user
	 *
	 * @param   string  $email  The email to set
	 *
	 * @return  void
	 */
	public function setEmail($email)
	{
		$this->email = $email;
	}

	/**
	 * Sets the password of the user. The password will be automatically hashed before being stored.
	 *
	 * For more information: http://blog.ircmaxell.com/2012/12/seven-ways-to-screw-up-bcrypt.html
	 *
	 * @param   string  $password  The (unhashed) password
	 *
	 * @return  void
	 *
	 * @throws  \RuntimeException  if no password hashing method is available (throw your server away!)
	 */
	public function setPassword($password)
	{
		// Check if the current PHP version supports the $2y$ prefix
		$pass = false;
		$hash = '$2y$04$usesomesillystringfore7hnbRJHxXVLeakoG8K30oukPsA.ztMG';
		if (function_exists('crypt'))
		{
			$test = crypt("password", $hash);
			$pass = $test == $hash;
		}

		if ($pass)
		{
			// On PHP 5.3.7 or later *OR* on older PHP 5.3.3 releases with the $2y$ fix backported into them we can use
			// the very secure BCrypt with a relatively high cost parameter (10)
			$this->password = password_hash($password, PASSWORD_BCRYPT, array("cost" => 10));
		}
		else
		{
			// On lower PHP versions we have to use the easier to crack salted MD5/SHA1/SHA256/SHA512 :(
			$salt = base64_encode($this->getSalt(64));

			$hash_algos = function_exists('hash_algos') ? hash_algos() : array();

			// Prefer SHA-512...
			if (in_array('sha512', $hash_algos))
			{
				$this->password = 'SHA512:' . hash('sha512', $password . $salt, false);
			}
			// ...then SHA-256...
			elseif (in_array('sha256', $hash_algos))
			{
				$this->password = 'SHA256:' . hash('sha256', $password . $salt, false);
			}
			// ...then SHA-1...
			elseif (function_exists('sha1'))
			{
				$this->password = 'SHA1:' . HashHelper::sha1($password . $salt);
			}
			// ...then MD5...
			elseif (function_exists('md5'))
			{
				$this->password = 'MD5:' . HashHelper::md5($password . $salt);
			}
			// ...and if all else fails throw an exception: your server is trash!
			else
			{
				throw new \RuntimeException('Your server does not support password hashing. Move to a decent host immediately!', 500);
			}

			$this->password .= ':' . $salt;
		}
	}

	/**
	 * Returns the hashed password of the user.
	 *
	 * @return  string
	 */
	public function getPassword()
	{
		return $this->password;
	}

	/**
	 * Checks if the provided (unhashed) password matches the password of the user record. It also triggers all
	 * authentication plugins which can implement alternative or two-factor authentication methods.
	 *
	 * @param   string  $password  The password to check
	 * @param   array   $options   [optional] Any additional information, e.g. two factor authentication
	 *
	 * @return  boolean
	 */
	public function verifyPassword($password, $options = array())
	{
		$params = array_merge($options, array('password' => $password));

		return $this->triggerAuthenticationEvent('onAuthentication', $params);
	}

	/**
	 * Gets the user's parameters. The parameters are stored in JSON format in the user record automatically. If you
	 * need to write to them you can use the returned Registry object instance.
	 *
	 * @return  Registry
	 */
	public function &getParameters()
	{
		if (empty($this->parameters) || !is_object($this->parameters) || !($this->parameters instanceof Registry))
		{
			$this->parameters = new Registry();
		}

		return $this->parameters;
	}

	/**
	 * Attach a privilege management object to the user object
	 *
	 * @param   string              $name             How this privilege will be known to the user class
	 * @param   PrivilegeInterface  $privilegeObject  The privilege management object to attach
	 *
	 * @return  void
	 */
	public function attachPrivilegePlugin($name, PrivilegeInterface $privilegeObject)
	{
		$this->privileges[$name] = $privilegeObject;
		$privilegeObject->setName($name);
		$privilegeObject->setUser($this);
	}

	/**
	 * Detach a privilege management object from the user object
	 *
	 * @param   string  $name  The name of the privilege to detach
	 *
	 * @return  void
	 */
	public function detachPrivilegePlugin($name)
	{
		if (array_key_exists($name, $this->privileges))
		{
			unset ($this->privileges[$name]);
		}
	}

	/**
	 * Get the value of a privilege. Privileges are two string separated by a dot, e.g. foo.bar which tells the user
	 * object to look for a privilege management object call "foo" and ask it to return the value of privilege "bar".
	 * If the object is not found or has no record of the privilege requested it will return the $default value.
	 *
	 * @param   string  $privilege  The privilege to check, e.g. foo.bar
	 * @param   bool    $default    The default privilege value (true = give access, false = forbid access)
	 *
	 * @return  bool  True if access is granted, false if access is not granted, null if undefined (avoid using null)
	 */
	public function getPrivilege($privilege, $default = false)
	{
		// If there is no dot in the privilege name it's an invalid privilege, so return the default value
		if (strpos($privilege, '.') === false)
		{
			return $default;
		}

		[$name, $subPrivilege] = explode('.', $privilege);

		// Did they ask for an unknown privilege?
		if (!array_key_exists($name, $this->privileges))
		{
			return $default;
		}

		/** @var PrivilegeInterface $privilegeObject */
		$privilegeObject = $this->privileges[$name];

		return $privilegeObject->getPrivilege($subPrivilege, $default);
	}

	/**
	 * Set the value of a privilege. Privileges are two string separated by a dot, e.g. foo.bar which tells the user
	 * object to look for a privilege management object call "foo" and ask it to return the value of privilege "bar".
	 * Not all privilege objects are supposed to implement the setPrivilege functionality. Then they return false.
	 *
	 * @param   string  $privilege  The privilege to check, e.g. foo.bar
	 * @param   mixed   $value      The privilege value (true = give access, false = forbid access)
	 *
	 * @return  boolean  False if setting the privilege is not supported
	 */
	public function setPrivilege($privilege, $value)
	{
		// If there is no dot in the privilege name it's an invalid privilege
		if (strpos($privilege, '.') === false)
		{
			return false;
		}

		[$name, $subPrivilege] = explode('.', $privilege);

		// Did they ask for an unknown privilege?
		if (!array_key_exists($name, $this->privileges))
		{
			return false;
		}

		/** @var PrivilegeInterface $privilegeObject */
		$privilegeObject = $this->privileges[$name];
		return $privilegeObject->setPrivilege($subPrivilege, $value);
	}

	/**
	 * Trigger a privilege plugin event
	 *
	 * @param   string  $event
	 *
	 * @return  void
	 */
	public function triggerEvent($event)
	{
		// Run the privileges' onAfterLoad events
		if (!empty($this->privileges))
		{
			/** @var  PrivilegeInterface  $privilegeObject */
			foreach ($this->privileges as $name => $privilegeObject)
			{
				if (method_exists($privilegeObject, $event))
				{
					$privilegeObject->$event();
				}
			}
		}
	}

	/**
	 * Trigger an authentication plugin event
	 *
	 * @param   string  $event   The event to run
	 * @param   array   $params  The parameters used for this event
	 *
	 * @return  boolean  True if all authentication objects report success
	 */
	public function triggerAuthenticationEvent($event, $params = array())
	{
		$result = false;

		// Run the authentication event
		if (!empty($this->authentications))
		{
			$result = true;

			/** @var  AuthenticationInterface  $authenticationObject */
			foreach ($this->authentications as $name => $authenticationObject)
			{
				if (method_exists($authenticationObject, $event))
				{
					$result = $result && $authenticationObject->$event($params);
				}

				// Break as soon as the first authentication object returns false
				if (!$result)
				{
					break;
				}
			}
		}

		return $result;
	}

	/**
	 * Attach a user authentication object to the user object
	 *
	 * @param   string                   $name                  How this authentication object will be known to the user class
	 * @param   AuthenticationInterface  $authenticationObject  The user authentication object to attach
	 *
	 * @return  void
	 */
	public function attachAuthenticationPlugin($name, AuthenticationInterface $authenticationObject)
	{
		$this->authentications[$name] = $authenticationObject;
		$authenticationObject->setName($name);
		$authenticationObject->setUser($this);
	}

	/**
	 * Detach a user authentication object from the user object
	 *
	 * @param   string  $name  The name of the user authentication object to detach
	 *
	 * @return  void
	 */
	public function detachAuthenticationPlugin($name)
	{
		if (array_key_exists($name, $this->authentications))
		{
			unset ($this->authentications[$name]);
		}
	}

	/**
	 * Gets a random salt for the RELATIVELY INSECURE salted SHA-512/SHA-256/SHA-1/MD5 password hashing. We will use
	 * mcrypt_create_iv, openssl_random_pseudo_bytes or mt_rand in this order, depending on what is available on the
	 * system.
	 *
	 * For more information: http://blog.ircmaxell.com/2012/12/seven-ways-to-screw-up-bcrypt.html
	 *
	 * @param   integer  $length  How long the random salt will be
	 *
	 * @return  string
	 */
	protected function getSalt($length = 16)
	{
		if (\function_exists('random_bytes'))
		{
			$salt = random_bytes($length);
		}
		elseif (function_exists('openssl_random_pseudo_bytes'))
		{
			$crypto_strong = null;
			$salt = openssl_random_pseudo_bytes($length, $crypto_strong);
		}
		else
		{
			$chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567890!@#$%^&*()_+-={}[]|;/?.>,<`~';
			$maxLength = strlen($chars) - 1;
			$salt = '';

			for ($i = 0; $i < $length; $i++)
			{
				$salt .= substr($chars, mt_rand(0, $maxLength), 1);
			}
		}

		return $salt;
	}
} 
