<?php namespace ProcessWire;

/**
 * ProcessWire UserPage
 *
 * A type of Page used for storing an individual User
 * 
 * ProcessWire 3.x, Copyright 2016 by Ryan Cramer
 * https://processwire.com
 *
 * #pw-summary The $user API variable is a type of page representing the current user, and the User class is Page type used for all users.
 *
 * @link http://processwire.com/api/variables/user/ Offical $user API variable Documentation
 *
 * @property string $email Get or set email address for this user.
 * @property string|Password $pass Set the user’s password. 
 * @property PageArray $roles Get the roles this user has. 
 * @property Language $language User language, applicable only if LanguageSupport installed.
 * @property string $admin_theme Admin theme class name
 * 
 * @method bool hasPagePermission($name, Page $page = null) #pw-internal
 * @method bool hasTemplatePermission($name, $template) #pw-internal
 * 
 * Additional notes regarding the $user->pass property: 
 * Note that when getting, this returns a hashed version of the password, so it is not typically useful to get this property. 
 * However, it is useful to set this property if you want to change the password. When you change a password, it is assumed 
 * to be the non-hashed/non-encrypted version. ProcessWire will hash it automatically when the user is saved.
 *
 */

class User extends Page {

	/**
	 * Cached value for $user->isSuperuser() checks
	 * 
	 * @var null|bool
	 * 
	 */
	protected $isSuperuser = null;

	/**
	 * Create a new User page in memory. 
	 *
	 * @param Template $tpl Template object this page should use. 
	 *
	 */
	public function __construct(Template $tpl = null) {
		parent::__construct($tpl); 
		if(is_null($tpl)) {
			$this->template = $this->wire('templates')->get('user');
		}
		if(!$this->parent_id) $this->set('parent_id', $this->wire('config')->usersPageID); 
	}
	
	/**
	 * Does this user have the given Role? 
	 * 
	 * ~~~~~
	 * if($user->hasRole('editor')) {
	 *   // user has the editor role
	 * }
	 * ~~~~~
	 *
	 * @param string|Role|int $role May be Role name, object or ID. 
	 * @return bool
	 *
	 */
	public function hasRole($role) {
		
		$roles = $this->get('roles');
		$has = false; 
		
		if(empty($roles)) {
			// do nothing
			
		} else if(is_object($role) && $role instanceof Page) {
			$has = $roles->has($role); 
			
		} else if(ctype_digit("$role")) {
			$role = (int) $role; 
			foreach($roles as $r) {
				if(((int) $r->id) === $role) {
					$has = true; 
					break;
				}
			}
			
		} else if(is_string($role)) {
			foreach($roles as $r) {
				if($r->name === $role) {
					$has = true;
					break;
				}
			}
		}
		
		return $has;
	}

	/**
	 * Add Role to this user 
	 *
	 * This is the same as `$user->roles->add($role)` except this one will also accept ID or name.
	 * 
	 * ~~~~~
	 * // Add the "editor" role to the $user
	 * $user->addRole('editor');
	 * $user->save();
	 * ~~~~~
	 *
	 * @param string|int|Role $role May be Role name, object, or ID. 
	 * @return bool Returns false if role not recognized, true otherwise
	 *
	 */
	public function addRole($role) {
		if(is_string($role) || is_int($role)) $role = $this->wire('roles')->get($role); 
		if(is_object($role) && $role instanceof Role) {
			$this->get('roles')->add($role); 
			return true; 
		}
		return false;
	}

	/**
	 * Remove Role from this user
	 *
	 * This is the same as `$user->roles->remove($role)` except this one will accept ID or name.
	 * 
	 * ~~~~~
	 * // Remove the "editor" role from the $user
	 * $user->removeRole('editor');
	 * $user->save();
	 * ~~~~~
	 *
	 * @param string|int|Role $role May be Role name, object or ID. 
	 * @return bool false if role not recognized, true otherwise
	 *
	 */
	public function removeRole($role) {
		if(is_string($role) || is_int($role)) $role = $this->wire('roles')->get($role); 
		if(is_object($role) && $role instanceof Role) {
			$this->get('roles')->remove($role); 
			return true; 
		}
		return false;
	}

	/**
	 * Does the user have the given permission? 
	 * 
	 * - Optionally accepts a `Page` or `Template` context for the permission.
	 * - This method accounts for the user's permissions across all their roles.  
	 * 
	 * ~~~~~
	 * if($user->hasPermission('page-publish')) {
	 *   // user has the page-publish permission in one of their roles
	 * }
	 * if($user->hasPermission('page-publish', $page)) {
	 *   // user has page-publish permission for $page
	 * }
	 * ~~~~~
	 * 
	 * @param string|Permission $name Permission name, object or id. 
	 * @param Page|Template|bool|string $context Page or Template... 
	 *  - or specify boolean true to return if user has permission OR if it was added at any template
	 *  - or specify string "templates" to return array of Template objects where user has permission
	 * @return bool|array
	 *
	 */
	public function hasPermission($name, $context = null) {
		// This method serves as the public interface to the hasPagePermission and hasTemplatePermission methods.
		
		if($context === null || $context instanceof Page) {
			$hook = $this->wire('hooks')->isHooked('hasPagePermission()');
			return $hook ? $this->hasPagePermission($name, $context) : $this->___hasPagePermission($name, $context);
		} 
		
		$hook = $this->wire('hooks')->isHooked('hasTemplatePermission()');
		
		if($context instanceof Template) {
			return $hook ? $this->hasTemplatePermission($name, $context) : $this->___hasTemplatePermission($name, $context);
		}
		
		if($context === true || $context === 'templates') {
			$addedTemplates = array();
			foreach($this->wire('templates') as $t) {
				if(!$t->useRoles) continue;
				$has = $hook ? $this->hasTemplatePermission($name, $t) : $this->___hasTemplatePermission($name, $t);
				if($has) $addedTemplates[] = $t;
				if($has && $context === true) break; // we only need to know if there is at least one, so break now
			}
			return $context === true ? count($addedTemplates) > 0 : $addedTemplates;	
		}
		
		return false;
	}

	/**
	 * Does this user have named permission for the given Page?
	 *
	 * This is a basic permission check and it is recommended that you use those from the PagePermissions module instead. 
	 * You use the PagePermissions module by calling the editable(), addable(), etc., functions on a page object. 
	 * The PagePermissions does use this function for some of it's checking. 
	 * 
	 * #pw-hooker
	 *
	 * @param string|Permission
	 * @param Page $page Optional page to check against
	 * @return bool
	 *
	 */
	protected function ___hasPagePermission($name, Page $page = null) {

		if($this->isSuperuser()) return true; 
		$permissions = $this->wire('permissions');

		// convert $name to a Permission object (if it isn't already)
		if($name instanceof Page) {
			$permission = $name;
		} else if(ctype_digit("$name")) {
			$permission = $permissions->get((int) $name);
		} else if($name == 'page-rename') {
			// optional permission that, if not installed, page-edit is substituted for
			if($this->wire('permissions')->has('page-rename')) {
				$permission = $permissions->get('page-rename');
			} else {
				$permission = $permissions->get('page-edit');
			}
		} else {
			if($name == 'page-add' || $name == 'page-create') {
				// page-add and page-create don't actually exist in the DB, so we substitute page-edit for them 
				// code later on will make sure they exist in the template's addRoles/createRoles
				$p = 'page-edit';
			} else if(!$permissions->has($name)) {
				$delegated = $permissions->getDelegatedPermissions();
				$p = isset($delegated[$name]) ? $delegated[$name] : $name;
			} else {
				$p = $name;
			}
			$permission = $permissions->get($p); 
		}

		if(!$permission || !$permission->id) return false;

		$roles = $this->getUnformatted('roles'); 
		if(empty($roles) || !$roles instanceof PageArray) return false; 
		$has = false; 
		$accessTemplate = is_null($page) ? false : $page->getAccessTemplate($permission->name);
		if(is_null($accessTemplate)) return false;

		foreach($roles as $key => $role) {

			if(!$role || !$role->id) continue; 
			$context = null;

			if(!is_null($page)) {
				// @todo some of this logic has been duplicated in Role::hasPermission, so code within this if() may be partially redundant
				if(!$page->id) continue;  

				// if page doesn't have the 'view' role, then no access
				if(!$page->hasAccessRole($role, $name)) continue; 

				// all page- permissions except page-view and page-add require page-edit access on $page, so check against that
				if(strpos($name, 'page-') === 0 && $name != 'page-view' && $name != 'page-add') {
					if($accessTemplate && !in_array($role->id, $accessTemplate->editRoles)) continue; 
				}

				// check against addRoles, createRoles if the permission requires it
				if($name == 'page-add') {
					if($accessTemplate && !in_array($role->id, $accessTemplate->addRoles)) continue;
				} else if($name == 'page-create') {
					if($accessTemplate && !in_array($role->id, $accessTemplate->createRoles)) continue;
				} else {
					// some other page-* permission, check against context of access template
					$context = $accessTemplate ? $accessTemplate : $page;
				}
			}

			if($role->hasPermission($permission, $context)) { 
				$has = true;
				break;
			}
		}
	
		return $has; 
	}


	/**
	 * Does this user have the given permission on the given template?
	 * 
	 * #pw-hooker
	 *
	 * @param string|Permission $name Permission name
	 * @param Template|int|string $template Template object, name or ID
	 * @return bool
	 * @throws WireException
	 *
	 */
	protected function ___hasTemplatePermission($name, $template) {
		
		if(is_object($name)) $name = $name->name; 

		if($this->isSuperuser()) return true; 

		if($template instanceof Template) {
			// fantastic then
		} else if(is_string($template) || is_int($template)) {
			$template = $this->templates->get($this->wire('sanitizer')->name($template)); 
			if(!$template) return false;
		} else {
			return false;
		}

		// if the template is not defining roles, we have to say 'no' to permission
		// because we don't have any page context to inherit from at this point
		// if(!$template->useRoles) return false; 

		$roles = $this->get('roles'); 
		if(empty($roles)) return false; 
		$has = false;

		foreach($roles as $role) {
			/** @var Role $role */
			
			// @todo much of this logic has been duplicated in Role::hasPermission, so code within this foreach() may be partially redundant

			if(!$template->hasRole($role)) continue; 

			if($name == 'page-create') { 
				if(!in_array($role->id, $template->createRoles)) continue; 
				$name = 'page-edit'; // swap permission to page-edit since create managed at template and requires page-edit
			}
			
			if($name == 'page-edit' && !in_array($role->id, $template->editRoles)) {
				continue;
			}

			if($name == 'page-add') {
				if(!in_array($role->id, $template->addRoles)) continue;
				$name = 'page-edit';
			}

			$context = null;
			if($name != 'page-edit' && $name != 'page-add' && $name != 'page-create' && $name != 'page-view') {
				if(strpos($name, "page-") === 0) $context = $template;
			}

			if($role->hasPermission($name, $context)) {
				$has = true;
				break;
			}
		}

		return $has; 
	}

	/**
	 * Get this user’s permissions, optionally within the context of a Page.
	 * 
	 * ~~~~~
	 * // Get all permissions the user has across their roles
	 * $permissions = $user->getPermissions(); 
	 * 
	 * // Get all permissions the user has for $page
	 * $permissions = $user->getPermissions($page); 
	 * ~~~~~
	 *
	 * @param Page $page Optional page to check against
	 * @return PageArray of Permission objects
	 *
	 */
	public function getPermissions(Page $page = null) {
		// Does not currently include page-add or page-create permissions (runtime).
		if($this->isSuperuser()) return $this->wire('permissions')->getIterator(); // all permissions
		$permissions = $this->wire('pages')->newPageArray();
		$roles = $this->get('roles'); 
		if(empty($roles)) return $permissions; 
		foreach($roles as $key => $role) {
			if($page && !$page->hasAccessRole($role)) continue; 
			foreach($role->permissions as $permission) { 
				if($page && $permission->name == 'page-edit') {
					$accessTemplate = $page->getAccessTemplate('edit');
					if(!$accessTemplate) continue;
					if(!in_array($role->id, $accessTemplate->editRoles)) continue; 
				}
				$permissions->add($permission); 
			}
		}
		return $permissions; 
	}

	/**
	 * Does this user have the superuser role?
	 *
	 * Same as calling `$user->roles->has('name=superuser');` but potentially faster. 
	 *
	 * @return bool
	 *
	 */
	public function isSuperuser() {
		if(is_bool($this->isSuperuser)) return $this->isSuperuser;
		$config = $this->wire('config');
		if($this->id === $config->superUserPageID) {
			$is = true;
		} else if($this->id === $config->guestUserPageID) {
			$is = false;
		} else {
			$superuserRoleID = (int) $config->superUserRolePageID;
			$roles = $this->getUnformatted('roles');
			if(empty($roles)) return false; // no cache intentional
			$is = false;
			foreach($roles as $role) if(((int) $role->id) === $superuserRoleID) {
				$is = true;
				break;
			}
		}
		$this->isSuperuser = $is;
		return $is;
	}

	/**
	 * Is this the non-logged in guest user? 
	 *
	 * @return bool
	 *
	 */ 
	public function isGuest() {
		return $this->id === $this->wire('config')->guestUserPageID; 
	}

	/**
	 * Is the current user logged in?
	 *
	 * @return bool
	 *
	 */
	public function isLoggedin() {
		return !$this->isGuest();
	}

	/**
	 * Get the value for a non-native User field
	 * 
	 * @param string $key
	 * @param string|Selectors|array $selector
	 * @return null|mixed
	 *
	 */
	protected function getFieldValue($key, $selector = '') {
		$value = parent::getFieldValue($key, $selector);
		if(!$value && $key == 'language') {
			$languages = $this->wire('languages');
			if($languages) $value = $languages->getDefault();
		}
		return $value;
	}

	/**
	 * Return the URL necessary to edit this user
	 * 
	 * In this case we adjust the default page editor URL to ensure users are edited
	 * only from the Access section. 
	 *
	 * #pw-internal
	 *
	 * @param array|bool $options Specify boolean true to force URL to include scheme and hostname, or use $options array:
	 *  - `http` (bool): True to force scheme and hostname in URL (default=auto detect).
	 * @return string URL for editing this user
	 *
	 */
	public function editUrl($options = array()) {
		return str_replace('/page/edit/', '/access/users/edit/', parent::editUrl($options));
	}

	/**
	 * Set the Process module (WirePageEditor) that is editing this User
	 * 
	 * We use this to detect when the User is being edited somewhere outside of /access/users/
	 * 
	 * #pw-internal
	 * 
	 * @param WirePageEditor $editor
	 * 
	 */
	public function ___setEditor(WirePageEditor $editor) {
		parent::___setEditor($editor); 
		if(!$editor instanceof ProcessUser) $this->wire('session')->redirect($this->editUrl());
	}

	/**
	 * Return the API variable used for managing pages of this type
	 * 
	 * #pw-internal
	 *
	 * @return Pages|PagesType
	 *
	 */
	public function getPagesManager() {
		return $this->wire('users');
	}

	/**
	 * Does user have two-factor authentication (Tfa) enabled? (and what type?)
	 *
	 * - Returns boolean false if not enabled. 
	 * - Returns string with Tfa module name (string) if enabled.
	 * - When `$getInstance` argument is true, returns Tfa module instance rather than module name.
	 * 
	 * The benefit of using this method is that it can identify if Tfa is enabled without fully 
	 * initializing a Tfa module that would attach hooks, etc. So when you only need to know if 
	 * Tfa is enabled for a user, this method is more efficient than accessing `$user->tfa_type`. 
	 * 
	 * When using `$getInstance` to return module instance, note that the module instance might not 
	 * be initialized (hooks not added, etc.). To retrieve an initialized instance, you can get it 
	 * from `$user->tfa_type` rather than calling this method. 
	 * 
	 * @param bool $getInstance Get Tfa module instance when available? (default=false) 
	 * @return bool|string|Tfa
	 * @since 3.0.162
	 * 
	 */
	public function hasTfa($getInstance = false) {
		return Tfa::getUserTfaType($this, $getInstance); 
	}

	/**
	 * Hook called when field has changed
	 * 
	 * @param string $what
	 * @param mixed $old
	 * @param mixed $new
	 * 
	 */
	public function ___changed($what, $old = null, $new = null) {
		if($what == 'roles' && is_bool($this->isSuperuser)) $this->isSuperuser = null;
		parent::___changed($what, $old, $new); 
	}

}
