<?php namespace ProcessWire;

/**
 * ProcessWire User Profile Editor
 *
 * ProcessWire 3.x, Copyright 2024 by Ryan Cramer
 * https://processwire.com
 *
 * @property array $profileFields Names of fields user is allowed to edit in their profile
 * @method bool|string isDisallowedUserName($value)
 *
 */

class ProcessProfile extends Process implements ConfigurableModule, WirePageEditor {

	public static function getModuleInfo() {
		return array(
			'title' => __('User Profile', __FILE__), // getModuleInfo title          
			'summary' => __('Enables user to change their password, email address and other settings that you define.', __FILE__), // getModuleInfo summary 
			'version' => 105, 
			'permanent' => true, 
			'permission' => 'profile-edit',
		);
	}
	
	/**
	 * @var User
	 * 
	 */
	protected $user;

	/**
	 * Label for user “name”
	 * 
	 * @var string
	 * 
	 */
	protected $userNameLabel = '';

	/**
	 * Password required for changes to these field names
	 * 
	 * @var array
	 * 
	 */
	protected $passRequiredNames = array();

	/**
	 * Construct/establish initial module configuration
	 * 
	 */
	public function __construct() {
		$this->set('profileFields', array()); 
		$this->userNameLabel = $this->_('User Login Name'); // Label for user login name
		parent::__construct();
	}

	/**
	 * Execute/render profile edit form
	 * 
	 * @return string
	 * @throws WireException
	 * 
	 */
	public function ___execute() {
		$input = $this->wire()->input;
		$config = $this->wire()->config;
		
		$fieldName = '';
		if(isset($_SERVER['HTTP_X_FIELDNAME']) && $input->requestMethod('POST') && $config->ajax) {
			$fieldName = $this->wire()->sanitizer->fieldName($_SERVER['HTTP_X_FIELDNAME']);
		}

		$this->user = $this->wire()->user; 
		$of = $this->user->of();
		if($of) $this->user->of(false);
		
		$this->headline($this->_("Profile:") . ' ' . $this->user->name); // Primary Headline (precedes the username)
		$form = $this->buildForm($fieldName);
		
		if($input->post('submit_save_profile') || $fieldName) {
			$this->processInput($form, $fieldName); 
			if($of) $this->user->of(true);
			if($fieldName) {
				// no need to redirect
			} else {
				$this->wire()->session->redirect("./");
			}
				
		} else { 
			$out = $form->render();
			if($of) $this->user->of(true);
			return $out;
		}
		return '';
	}	

	/**
	 * Build the form fields for adding a page
	 * 
	 * @param string $fieldName
	 * @return InputfieldForm
	 *
	 */
	protected function buildForm($fieldName = '') {
	
		$user = $this->user;
		$modules = $this->wire()->modules;
		$config = $this->wire()->config;

		/** @var InputfieldForm $form */
		$form = $modules->get('InputfieldForm');
		$form->attr('id', 'ProcessProfile'); 
		$form->attr('action', './'); 
		$form->attr('method', 'post'); 
		$form->attr('enctype', 'multipart/form-data');
		$form->attr('autocomplete', 'off'); 
		$form->addClass('InputfieldFormConfirm');
		
		$fieldset = new InputfieldWrapper();
		$this->wire($fieldset);
		$form->add($fieldset);
	
		// is password required to change some Inputfields?
		$passRequired = false;
		// Inputfields where password is required to change
		$passRequiredInputfields = array();
		$config->js('ProcessProfile', array(
			'passRequiredAlert' => $this->_('For security, please enter your current password to save these changes:')
		));
		
		/** @var JqueryUI $jQueryUI */
		$jQueryUI = $modules->get('JqueryUI');
		$jQueryUI->use('vex');
		
		if(in_array('name', $this->profileFields) && empty($fieldName)) {
			/** @var InputfieldText $f */
			$f = $modules->get('InputfieldText');
			$f->attr('id+name', '_user_name');
			$f->label = $this->userNameLabel;
			$f->description = $this->_('User name may contain lowercase a-z, 0-9, hyphen or underscore.'); 
			$f->icon = 'sign-in';
			$f->attr('value', $user->name);
			$f->attr('pattern', '^[-_a-z0-9]+$'); 
			$f->required = true; 
			$fieldset->add($f);
			$f->setTrackChanges(true);
			$passRequiredInputfields[] = $f;
		}

		foreach($user->fields as $field) {
			/** @var Field $field */
			if($field->name == 'roles' || !in_array($field->name, $this->profileFields)) continue;
			if($fieldName && $field->name !== $fieldName) continue;
			$field = $user->fields->getFieldContext($field);
			$inputfield = $field->getInputfield($user);
			if(!$inputfield) continue;
			$inputfield->val($user->get($field->name));
			
			if($field->name === 'admin_theme') {
				if(!$inputfield->value) $inputfield->value = $config->defaultAdminTheme;
				
			} else if($field->type instanceof FieldtypeImage) {
				if(!$user->hasPermission('page-edit-images', $user)) {
					$inputfield->set('useImageEditor', false);
				}
				
			} else if($field->type instanceof FieldtypePassword && $field->name == 'pass') {
				$inputfield->attr('autocomplete', 'off');
				if($inputfield->getSetting('requireOld') == InputfieldPassword::requireOldAuto) {
					$inputfield->set('requireOld', InputfieldPassword::requireOldYes); 
				}
				if($inputfield->getSetting('requireOld') == InputfieldPassword::requireOldYes) {
					$passRequired = true; 
				}
				if(!$inputfield->getSetting('icon')) $inputfield->set('icon', 'key');
				
			} else if($field->name === 'email') {
				if(!$inputfield->getSetting('icon')) $inputfield->set('icon', 'envelope-o');
				if(strlen($inputfield->value)) {
					$passRequiredInputfields[] = $inputfield;
				}
			} else if($field->name === 'tfa_type') {
				$passRequiredInputfields[] = $inputfield;
				if(!$inputfield->val()) {
					// initialize manually so it can add hooks (it just does some visual/wording tweaks)
					$tfa = $this->wire(new Tfa()); /** @var Tfa $tfa */
					$tfa->init(); 
				}
			}
			
			$fieldset->add($inputfield); 
		}
	
		/** @var InputfieldHidden $f */
		// note used for processing, present only for front-end JS compatibility with ProcessPageEdit
		$f = $modules->get('InputfieldHidden');
		$f->attr('id', 'Inputfield_id');
		$f->attr('name', 'id'); 
		$f->attr('value', $user->id);
		$f->addClass('InputfieldAllowAjaxUpload');
		$fieldset->add($f);

		/** @var InputfieldSubmit $field */
		$field = $modules->get('InputfieldSubmit');
		$field->attr('id+name', 'submit_save_profile'); 
		$field->showInHeader();
		$form->add($field); 
		
		if($passRequired && count($passRequiredInputfields)) {
			foreach($passRequiredInputfields as $f) {
				$f->addClass('InputfieldPassRequired', 'wrapClass');
				$this->passRequiredNames[$f->name] = $f->name;
			}
		}
	
		return $form; 
	}

	/**
	 * Save the submitted page add form
	 * 
	 * @param Inputfield $form
	 * @param string $fieldName
	 * @throws WireException
	 *
	 */
	protected function processInput(Inputfield $form, $fieldName = '') {
		
		/** @var InputfieldForm $form */

		$user = $this->user; 
		$input = $this->wire()->input;
		$languages = $this->wire()->languages;
		
		$this->wire()->session->CSRF()->validate();
		
		$form->processInput($input->post);

		if(count($form->getErrors())) {
			$this->error($this->_("Profile not saved")); 
			return;
		}
		
		$passValue = $input->post->string('_old_pass');
		
		if(strlen($passValue)) {
			$passAuthenticated = $user->pass->matches($passValue);
			$passFailedMessage = $this->_('Required password was provided but is not correct');
		} else {
			$passAuthenticated = false;
			$passFailedMessage = $this->_('Required password was not provided');
		}
		
		$user->of(false);
		$user->setTrackChanges(true);

		if(in_array('name', $this->profileFields) && empty($fieldName)) {
			$f = $form->getChildByName('_user_name');
			if($f && $f->isChanged()) {
				if(isset($this->passRequiredNames[$f->name]) && !$passAuthenticated) {
					$f->error($passFailedMessage);
				} else {
					$this->processInputUsername($f);
				}
			}
		}

		foreach($user->fields as $field) {
			/** @var Field $field */

			if($field->name == 'roles' || !in_array($field->name, $this->profileFields)) continue;
			if($fieldName && $field->name !== $fieldName) continue; 
			
			$field = $user->fields->getFieldContext($field);
			$inputfield = $form->getChildByName($field->name); 	
			$value = $inputfield->attr('value');
			
			if(empty($value) && in_array($field->name, array('pass', 'email'))) continue;
			
			if($field->name == 'email' && strlen($value)) {
				$selector = "id!=$user->id, include=all, email=" . $this->sanitizer->selectorValue($value);
				if(count($this->users->find($selector))) {
					$this->error(sprintf($this->_('Email address "%s" already in use by another user.'), $value));
					continue; 
				}
			}
			
			$userValue = $user->get($field->name);
			if($field->type instanceof FieldtypeModule) $userValue = "$userValue";
			$changed = false;
			
			if($inputfield->isChanged()) {
				$changed = true;
			} else if(is_array($value) && $userValue instanceof WireData) { // i.e. Combo
				$userValueArray = $userValue->getArray();
				$changed = $userValueArray != $value;
			} else if($userValue !== $value) {
				$changed = true;
			}
			
			if(!$changed) continue;
				
			if(isset($this->passRequiredNames[$inputfield->name]) && !$passAuthenticated) {
				$inputfield->error($passFailedMessage);
				continue;	
			}

			if($languages && $inputfield->getSetting('useLanguages')) {
				if(is_object($userValue)) {
					$userValue->setFromInputfield($inputfield);
					$user->set($field->name, $userValue);
					$user->trackChange($field->name);
				} else {
					$user->set($field->name, $value);
				}
			} else {
				$user->set($field->name, $value);
			}
		}

		if($user->isChanged()) {
			$changes = implode(', ', array_unique($user->getChanges())); 
			$message = $this->_('Profile saved') . ' - ' . $changes; 
			$this->message($message);
			$this->wire()->log->message($message); 
			$this->wire()->users->save($user);
		}

		$user->of(true); 
	}

	/**
	 * Process username inputfield
	 * 
	 * @param Inputfield $f The _user_name Inputfield
	 * @return bool Returns true if username changed allowed, false if not
	 * 
	 */
	protected function processInputUsername(Inputfield $f) {
		
		$user = $this->user;
		$userName = $this->wire()->sanitizer->pageName($f->val());
		
		if(empty($userName)) return false;
		if($f->val() === $user->name) return false; // no change
		if($userName === $user->name) return false; // no change after sanitization
	
		/* at this point we know that user changed their name */
	
		$error = $this->isDisallowedUserName($f->val()); 
		if($error !== false) {
			$f->error($error); 
			return false;
		}
		
		$user->name = $userName;

		$languages = $this->wire()->languages;
		if($languages && $languages->hasPageNames()) {
			foreach($languages as $language) {
				if(!$language->isDefault()) $user->set("name$language->id", $userName);
			}
		}
		
		return true; 
	}
	
	/**
	 * Return error message if user name is not allowed (to change to) or boolean false if it is
	 *
	 * @param string $value User name
	 * @return bool|string
	 *
	 */
	public function ___isDisallowedUserName($value) {

		$disallowedNames = array(
			'superuser',
			'admin',
			'administrator',
			'root',
			'guest',
			'nobody',
		);

		$languages = $this->wire()->languages;
		$notAllowedLabel = $this->_('Not allowed');
		$userName = $this->wire()->sanitizer->pageName($value);
		
		if($userName !== $value) {
			return sprintf($this->_('Sanitized to “%s”, which differs from what you entered'), $userName);
		}

		if(strlen($userName) < 3) {
			return $this->_('Too short');
		} else if(strlen($userName) > 64) {
			return $this->_('Too long');
		}

		if(in_array($userName, $disallowedNames)) {
			return "$notAllowedLabel (#1)";
		}

		// check if user name is already in use
		if($languages) $languages->setDefault();
		$u = $this->wire()->users->get("name='$userName', include=all");
		if($languages) $languages->unsetDefault();
		if($u->id) {
			return $this->_('Already in use');
		}

		$role = $this->wire()->roles->get("name='$userName', include=all");
		if($role->id) {
			return "$notAllowedLabel (#2)";
		}

		if(!ctype_alnum(substr($userName, 0, 1)) || !ctype_alnum(substr($userName, -1))) {
			return $this->_('May not start or end with non-alpha, non-digit characters');
		}

		if(preg_match('/[-_.]{2,}/', $userName)) {
			return $this->_('May not contain adjacent hyphens, underscores or periods');
		}

		return false;
	}

	/**
	 * Module configuration
	 * 
	 * @param array $data
	 * @return InputfieldWrapper
	 * @throws WireException
	 * 
	 */
	public function getModuleConfigInputfields(array $data) {

		$profileFields = isset($data['profileFields']) ? $data['profileFields'] : array();
		$fieldOptions = array();
		
		foreach($this->wire()->users->getTemplates() as $template) {
			foreach($template->fieldgroup as $field) {
				$fieldOptions[$field->name] = $field;
			}
		}
		
		ksort($fieldOptions);

		$inputfields = $this->wire(new InputfieldWrapper());
		
		/** @var InputfieldCheckboxes $f */
		$f = $this->wire()->modules->get('InputfieldCheckboxes');
		$f->label = $this->_("What fields can a user edit in their own profile?");
		$f->attr('id+name', 'profileFields');
		$f->icon = 'user-circle';
		$f->table = true;
		$f->thead = 
			$this->_('Name') . '|' . 
			$this->_('Label') . '|' . 
			$this->_('Type');
		
		$f->addOption('name', "name|$this->userNameLabel|System"); 
		
		foreach($fieldOptions as $name => $field) {
			if($name == 'roles') continue;
			$f->addOption($name, $name . '|' . str_replace('|', ' ', $field->getLabel()) . '|' . $field->type->shortName); 
		}
		
		$f->attr('value', $profileFields);
		$inputfields->add($f);

		return $inputfields;
	}

	/**
	 * For WirePageEditor interface
	 * 
	 * @return Page
	 * 
	 */
	public function getPage() {
		return $this->user ? $this->user : $this->wire()->user;
	}


}
