<?php namespace ProcessWire;

/**
 * An Inputfield for handling a password
 * 
 * ProcessWire 3.x, Copyright 2023 by Ryan Cramer
 * https://processwire.com
 * 
 * @property array $requirements Array of requirements (See require* constants)
 * @property array $requirementsLabels Text labels used for requirements
 * @property string $complexifyBanMode Complexify ban mode, 'loose' or 'strict' (default='loose')
 * @property float $complexifyFactor Complexify factor, lower numbers enable simpler passwords (default=0.7)
 * @property int $requireOld Require previous password? 0=Auto, 1=Yes, -1=No, Default=0 (Auto)
 * @property bool $showPass Allow password to be rendered in renderValue and/or re-populated in form?
 * @property bool $unmask Allow passwords to be unmasked? (3.0.173+)
 * @property string $defaultLabel Default label for field (default='Set Password'). Used when no 'label' has been set.
 * @property string $oldPassLabel Label for old/current password placeholder. 
 * @property string $newPassLabel Label for new password placeholder. 
 * @property string $confirmLabel Label for password confirm placeholder. 
 *
 */
class InputfieldPassword extends InputfieldText {

	public static function getModuleInfo() {
		return array(
			'title' => __('Password', __FILE__), // Module Title
			'summary' => __("Password input with confirmation field that doesn't ever echo the input back.", __FILE__), // Module Summary
			'version' => 102,
			'permanent' => true, 
		);
	}

	/**
	 * Requirements: letter required
	 * 
	 */
	const requireLetter = 'letter';
	
	/**
	 * Requirements: lowercase letter required
	 *
	 */
	const requireLowerLetter = 'lower';
	
	/**
	 * Requirements: uppercase letter required
	 *
	 */
	const requireUpperLetter = 'upper';
	
	/**
	 * Requirements: digit required
	 *
	 */
	const requireDigit = 'digit';
	
	/**
	 * Requirements: other character (symbol) required
	 *
	 */
	const requireOther = 'other';

	/**
	 * Requirements: disable all above
	 * 
	 */
	const requireNone = 'none';

	/**
	 * Require old password before changes? Auto
	 * 
	 */
	const requireOldAuto = 0;
	
	/**
	 * Require old password before changes? Yes
	 *
	 */
	const requireOldYes = 1;
	
	/**
	 * Require old password before changes? No
	 *
	 */
	const requireOldNo = -1;

	/**
	 * Page being edited, when applicable
	 * 
	 * @var User|Page|null
	 * 
	 */
	protected $_page = null;

	/**
	 * Construct and establish default settings
	 * 
	 */
	public function __construct() {
		parent::__construct();
		$this->attr('type', 'password'); 
		$this->attr('size', 30); 
		$this->attr('maxlength', 256); 
		$this->attr('minlength', 6); 
		$this->set('requireOld', false);
		$this->set('requirements', array(self::requireLetter, self::requireDigit));
		$this->set('complexifyFactor', 0.7);
		$this->set('complexifyBanMode', 'loose'); 
		$this->set('showPass', false); // allow password to be rendered in renderValue and/or re-populated in form?
		$this->set('unmask', false); 
	}

	/**
	 * Init Inputfield, establishing the label if none has been set
	 * 
	 */
	public function init() {
		parent::init();
		$defaultLabel = $this->_('Set Password');
		$this->set('defaultLabel', $defaultLabel);
		$this->set('requirementsLabels', array(
			self::requireLetter => $this->_('letter'),
			self::requireLowerLetter => $this->_('lowercase letter'),
			self::requireUpperLetter => $this->_('uppercase letter'),
			self::requireDigit => $this->_('digit'),
			self::requireOther => $this->_('symbol/punctuation'),
			self::requireNone => $this->_('none (disable all above)'),
		));
		$this->set('oldPassLabel', $this->_('Current password'));
		$this->set('newPassLabel', $this->_('New password'));
		$this->set('confirmLabel', $this->_('Confirm'));
		$this->label = $defaultLabel;
	}

	/**
	 * Sets the page being edited, not always applicable
	 * 
	 * @param Page $page
	 * 
	 */
	public function setPage(Page $page) { 
		$this->_page = $page; 
		if($page->hasStatus(Page::statusUnpublished)) $this->required = true; 
	}

	/**
	 * Called before render
	 * 
	 * @param Inputfield|null $parent
	 * @param bool $renderValueMode
	 * @return bool
	 * @throws WireException
	 * 
	 */
	public function renderReady(?Inputfield $parent = null, $renderValueMode = false) {
		if($this->label == 'Set Password') $this->label = $this->defaultLabel;
		$config = $this->wire()->config;
		$url = $config->urls('InputfieldPassword') . 'complexify/';
		$config->scripts->add($url . 'jquery.complexify.min.js');
		$config->scripts->add($url . 'jquery.complexify.banlist.js');
		$jQueryCore = $this->wire()->modules->get('JqueryCore'); /** @var JqueryCore $jQueryCore */
		$jQueryCore->use('xregexp');
		$page = $this->wire()->page;
		if(($page && $page->template->name == 'admin') || $this->wire()->user->isLoggedin()) {
			$this->attr('autocomplete', 'new-password'); // ProcessProfile and ProcessUser
		}
		return parent::renderReady($parent, $renderValueMode);
	}

	/**
	 * Render Password input(s)
	 * 
	 * @return string
	 * 
	 */
	public function ___render() {

		$sanitizer = $this->wire()->sanitizer;
		
		$minlength = (int) $this->attr('minlength');
		$requirements = array();
		if($minlength) {
			$requirements['minlength'] = "[span.pass-require.pass-require-minlength]" . 
				sprintf($this->_('at least %d characters long'), $minlength) . "[/span]";
		}
		$labels = $this->getSetting('requirementsLabels');
		if(!in_array(self::requireNone, $this->getSetting('requirements'))) {
			foreach($this->getSetting('requirements') as $name) {
				$requirements[$name] = "[span.pass-require.pass-require-$name]" . $labels[$name] . "[/span]";
			}
		}
		if(isset($requirements['upper']) || isset($requirements['lower'])) {
			unset($requirements['letter']); 
		}
		if(count($requirements)) {
			$description = implode(", ", $requirements);
			$description = sprintf($this->_('Minimum requirements: %s.'), $description);
			$description = trim("$description  " . $this->getSetting('description'));
			$this->set('description', $description);
		}
	
		$value = $this->attr('value'); 
		$confirmValue = '';
		
		$trackChanges = $this->trackChanges();
		if($trackChanges) $this->setTrackChanges(false);
		
		if(!$this->getSetting('showPass')) {
			$this->attr('value', '');
		} else {
			$confirmValue = $sanitizer->entities($value);
		}
		
		$this->attr('data-banMode', $this->complexifyBanMode ? $this->complexifyBanMode : 'loose');
		$this->attr('data-factor', (float) $this->complexifyFactor >= 0 ? str_replace(',', '.', "$this->complexifyFactor") : 0);
		
		$inputClass = $sanitizer->entities($this->attr('class'));
		$this->addClass('InputfieldPasswordComplexify');
		
		$failIcon = "<i class='fa fa-fw fa-frown-o'></i>";
		$okIcon = "<i class='fa fa-fw fa-meh-o'></i>";
		$goodIcon = "<i class='fa fa-fw fa-smile-o'></i>";
		$oldPassLabel = $sanitizer->entities1($this->oldPassLabel);
		$newPassLabel = $sanitizer->entities1($this->newPassLabel);
		$confirmLabel = $sanitizer->entities1($this->confirmLabel);
		$name = $this->attr('name');
		$id = $this->attr('id'); 
		$size = $this->attr('size');
		$out = '';
		
		if((int) $this->requireOld > 0 && $this->wire()->user->isLoggedin()) {
			$out .= 
				"<p class='InputfieldPasswordRow'>" .
					"<label for='_old_$name'>$oldPassLabel</label>" . 
					"<input placeholder='$oldPassLabel' class='InputfieldPasswordOld $inputClass' type='password' " . 
						"size='$size' id='_old_$name' name='_old_$name' value='' autocomplete='new-password' /> " .
				"</p>";
		}
		
		$out .=
			"<p class='InputfieldPasswordRow'>" .
				"<label for='$id'>$newPassLabel</label>" . 
				"<input placeholder='$newPassLabel' " . $this->getAttributesString() . " /> " . 
				"<span class='detail pass-scores' data-requirements='" . implode(' ', array_keys($requirements)) . "'>" .
					//"<span class='on'>$angleIcon$newPassLabel</span>" . 
					"<span class='pass-fail'>$failIcon" . $this->_('Not yet valid') . "</span>" .
					"<span class='pass-invalid'>$failIcon" . $this->_('Invalid') . "</span>" .
					"<span class='pass-short'>$failIcon" . $this->_('Too short') . "</span>" .
					"<span class='pass-common'>$failIcon" . $this->_('Too common') . "</span>" .
					"<span class='pass-same'>$failIcon" . $this->_('Same as old') . "</span>" . 
					"<span class='pass-weak'>$okIcon" . $this->_('Weak') . "</span>" .
					"<span class='pass-medium'>$okIcon" . $this->_('Ok') . "</span>" .
					"<span class='pass-good'>$goodIcon" . $this->_('Good') . "</span>" .
					"<span class='pass-excellent'>$goodIcon" . $this->_('Excellent') . "</span>" . 
				"</span>" . 
			"</p>" . 
			"<p class='InputfieldPasswordRow'>" .
				"<label for='_$id'>$confirmLabel</label>" . 
				"<input placeholder='$confirmLabel' class='InputfieldPasswordConfirm $inputClass' type='password' " . 
					"size='$size' id='_$id' name='_$name' value='$confirmValue' autocomplete='new-password' /> " . 
				"<span class='pass-confirm detail'>" . 
					//"<span class='confirm-pending on'>$angleIcon$newPassLabel ($confirmLabel)</span>" . 
					"<span class='confirm-yes'>$goodIcon" . $this->_('Matches') . "</span>" .
					"<span class='confirm-no'>$failIcon" . $this->_('Does not match') . "</span>" . 
					"<span class='confirm-qty'>$okIcon<span></span></span>" . 
				"</span>" . 
			"</p>";
	
		if($this->unmask) {
			$out .=
				"<p class='pass-mask detail'>" .
					"<a class='pass-mask-show' href='#'>" . $this->_('Show Password') . "</a>" .
					"<a class='pass-mask-hide' href='#'>" . $this->_('Hide Password') . "</a>" .
				"</p>";
		}
		
		$this->attr('value', $value);
		if($trackChanges) $this->setTrackChanges(true);
		
		return $out; 
	}

	/**
	 * Set Inputfield setting
	 * 
	 * @param string $key
	 * @param mixed $value
	 * @return Inputfield|InputfieldPassword
	 * 
	 */
	public function set($key, $value) {
		if($key == 'collapsed' && $this->_page && $this->_page->hasStatus(Page::statusUnpublished)) {
			// prevent collapse of field when pass is for unpublished user
			$value = Inputfield::collapsedNo; 
		}
		return parent::set($key, $value);
	}

	/**
	 * Render non-editable Inputfield
	 * 
	 * @return string
	 * 
	 */
	public function ___renderValue() {
		if(!$this->getSetting('showPass')) {
			$value = strlen($this->attr('value')) ? '******' : '';
		} else {
			$value = $this->wire()->sanitizer->entities($this->attr('value'));
		}
		$value = strlen($value) ? "<p>$value</p>" : "";
		return $value; 
	}

	/**
	 * Process input
	 * 
	 * @param WireInputData $input
	 * @return $this
	 * 
	 */
	public function ___processInput(WireInputData $input) {

		parent::___processInput($input);

		$user = $this->wire()->user;
		$key = $this->attr('name'); 
		$value = $this->attr('value');
		if($value) {}
		$confirmKey = "_" . $key;

		if(!isset($input->$key)) return $this;
		
		// form was submitted
		$pass = (string) $input->$key;
		$confirmPass = (string) $input->$confirmKey;

		if(strlen($pass) && strlen($confirmPass)) { 
			// password was submitted (with confirmation)
			$allowInput = true;
			
			if($this->requireOld > 0 && $user->isLoggedin()) {
				// old password is required to change
				$oldKey = "_old_" . $key;
				$oldPass = $input->$oldKey;
				if(!strlen($oldPass)) {
					$this->error($this->_('Old password is required in order to enter a new one.'));
					$allowInput = false;
				} else if(!$user->pass->matches($oldPass)) {
					$this->error($this->_('The old password you entered is not correct.'));
					$allowInput = false;
				}
			}

			if($allowInput) {
				if($confirmPass !== $pass) {
					$this->error($this->_("Passwords do not match"));
				}
				$this->isValidPassword($pass);
			}
			
		} else if($this->required) {
			$this->error($this->_("Required password was not specified")); 
		}

		if(count($this->getErrors())) {
			$this->attr('value', ''); 
			$this->resetTrackChanges(); // don't record a change
		}

		return $this;
	}

	/**
	 * Return whether or not the given password is valid according to configured requirements
	 * 
	 * Exact error messages can be retrieved with $this->getErrors().
	 * 
	 * @param string $value Password to validate
	 * @return bool
	 * 
	 */
	public function isValidPassword($value) {
		
		$numErrors = 0;
		$requirements = $this->getSetting('requirements');
		
		if(preg_match('/[\t\r\n]/', $value)) {
			$this->error($this->_("Password contained invalid whitespace"));
			$numErrors++;
		}

		if(strlen($value) < $this->attr('minlength')) {
			$this->error($this->_("Password is less than required number of characters"));
			$numErrors++;
		}

		if(in_array(self::requireNone, $requirements)) {
			// early exit if all following requirements are disabled
			return $numErrors === 0;
		}
		
		if(in_array(self::requireLetter, $requirements)) {
			// if(!preg_match('/[a-zA-Z]/', $value)) {
			if(!preg_match('/\p{L}/', $value)) {
				$this->error($this->_("Password does not contain at least one letter (a-z A-Z)"));
				$numErrors++;
			}
		}

		if(in_array(self::requireLowerLetter, $requirements)) {
			if(!preg_match('/\p{Ll}/', $value)) {
				$this->error($this->_("Password must have at least one lowercase letter (a-z)"));
				$numErrors++;
			}
		}

		if(in_array(self::requireUpperLetter, $requirements)) {
			if(!preg_match('/\p{Lu}/', $value)) {
				$this->error($this->_("Password must have at least one uppercase letter (A-Z)"));
				$numErrors++;
			}
		}

		if(in_array(self::requireDigit, $requirements)) {
			if(!preg_match('/\p{N}/', $value)) {
				$this->error($this->_("Password does not contain at least one digit (0-9)"));
				$numErrors++;
			}
		}

		if(in_array(self::requireOther, $requirements)) {
			if(!preg_match('/\p{P}/', $value) && !preg_match('/\p{S}/', $value)) {
				$this->error($this->_("Password must have at least one non-letter, non-digit character (like punctuation)"));
				$numErrors++;
			}	
		}
		
		return $numErrors === 0;
	}

	/**
	 * Return the fields required to configure an instance of InputfieldPassword
	 * 
	 * @return InputfieldWrapper
	 *
	 */
	public function ___getConfigInputfields() {
	
		$modules = $this->wire()->modules;
		$inputfields = parent::___getConfigInputfields();
		$skips = array(
			'collapsed', 
			'showIf', 
			'placeholder', 
			'stripTags', 
			'pattern', 
			'visibility', 
			'minlength', 
			'maxlength', 
			'showCount',
			'size',
		);
		
		foreach($skips as $name) {
			$f = $inputfields->get($name);
			if($f) $inputfields->remove($f); 
		}
	
		/** @var InputfieldCheckboxes $f */
		$f = $modules->get('InputfieldCheckboxes'); 
		$f->attr('name', 'requirements');
		$f->label = $this->_('Password requirements');
		foreach($this->getSetting('requirementsLabels') as $value => $label) {
			$f->addOption($value, $label);
		}
		$value = $this->getSetting('requirements'); 
		if(in_array(self::requireNone, $value)) $value = array('none');
		$f->attr('value', $value);
		$f->columnWidth = 50;
		$inputfields->add($f);
	
		/** @var InputfieldRadios $f */
		$f = $modules->get('InputfieldRadios');
		$f->attr('name', 'complexifyBanMode');
		$f->label = $this->_('Word ban mode');
		$f->description = $this->_('If you choose the strict mode, many passwords containing words will not be accepted.');
		$f->addOption('loose', $this->_('Ban just common passwords (recommended)'));
		$f->addOption('strict', $this->_('Ban all passwords containing any common words (strict)'));
		$f->attr('value', $this->complexifyBanMode);
		$f->columnWidth = 50;
		$inputfields->add($f);
	
		/** @var InputfieldFloat $f */
		$f = $modules->get('InputfieldFloat');
		$f->attr('name', 'complexifyFactor');
		$f->label = $this->_('Complexify factor');
		$f->description = $this->_('Lower numbers allow weaker passwords, higher numbers require stronger passwords.');
		$f->description .= ' ' . $this->_('Specify -1 to disable this feature.'); 
		$f->notes = $this->_('We recommend something between 0.5 and 1.0');
		$f->attr('value', $this->complexifyFactor);
		$f->columnWidth = 50;
		$inputfields->add($f);

		/** @var InputfieldInteger $f */
		$f = $modules->get('InputfieldInteger');
		$f->attr('name', 'minlength'); 
		$f->label = $this->_('Minimum password length'); 
		$f->attr('value', $this->attr('minlength'));
		$f->attr('min', 3); 
		$f->columnWidth = 50;
		$inputfields->add($f);
		
		if(!$this->getSetting('hasFieldtype')) {
			/** @var InputfieldCheckbox $f */
			$f = $modules->get('InputfieldCheckbox');
			$f->attr('name', 'showPass');
			$f->label = $this->_('Allow existing passwords to be shown and/or rendered in form?');
			if($this->getSetting("showPass")) $f->attr('checked', 'checked');
			$inputfields->add($f);
		}

		/** @var InputfieldRadios $f */
		$f = $modules->get('InputfieldRadios');
		$f->attr('name', 'requireOld'); 
		$f->label = $this->_('Require old password before allowing changes?');
		$f->description = $this->_('Applies to usages where this field appears to already logged-in users only.');
		$f->addOption(self::requireOldAuto, $this->_('Auto')); 
		$f->addOption(self::requireOldYes, $this->_('Yes'));
		$f->addOption(self::requireOldNo, $this->_('No'));
		$f->optionColumns = 1; 
		$f->attr('value', (int) $this->requireOld);
		$inputfields->add($f);
	
		/** @var InputfieldRadios $f */
		$f = $modules->get('InputfieldRadios');
		$f->attr('name', 'unmask'); 
		$f->label = $this->_('Allow user to show/unmask password during changes?'); 
		$f->description = $this->_('Provides a show/hide password control so users can see what they type when in an appropriate environment.');
		$f->addOption(1, $this->_('Yes'));
		$f->addOption(0, $this->_('No'));
		$f->optionColumns = 1;
		$f->attr('value', (int) $this->unmask);
		$inputfields->add($f);
		
		return $inputfields; 
	}
}
