<?php

/**
 * THIS SOFTWARE IS PROVIDED "AS IS" AND WITHOUT ANY EXPRESS OR IMPLIED
 * WARRANTIES, INCLUDING, WITHOUT LIMITATION, THE IMPLIED WARRANTIES OF
 * MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.
 *
 * @package fusionLib
 * @copyright Copyright (c) 2007 - 2016 fusionLib. All rights reserved.
 * @link http://fusionlib.com
 */

/**
 * flFormElement Class
 *
 * Base class for all form elements.
 *
 * @package fusionLib
 */
abstract class flFormElement {

	/**
	 * The name of the element.
	 *
	 * @var string
	 */
	protected $name;

	/**
	 * The label for the element.
	 *
	 * @var string
	 */
	protected $label = null;

	/**
	 * The title for the element.
	 *
	 * @var string
	 */
	protected $title = null;

	/**
	 * The description for the element.
	 *
	 * @var string
	 */
	protected $description = null;

	/**
	 * The value of the element.
	 *
	 * @var string
	 */
	protected $value;

	/**
	 * Flags the element as readonly.
	 *
	 * @param bool
	 */
	protected $readonly = false;

	/**
	 * Flags the element as disabled.
	 *
	 * @param bool
	 */
	protected $disabled = false;

	/**
	 * Flags element is required.
	 *
	 * @var bool
	 */
	protected $required = false;

	/**
	 * Element validators.
	 *
	 * @var array
	 */
	protected $validators = array();

	/**
	 * The first error generated during validating the form.
	 *
	 * @var array
	 */
	protected $error = null;

	/**
	 * Flags if the element is valid according to the validators.
	 *
	 * @var bool
	 */
	protected $isValid = true;

	/**
	 * Flags if data has been submitted to the element.
	 *
	 * @var bool
	 */
	private $isSubmitted = false;

	/**
	 * The form the element belongs to.
	 *
	 * @var flForm
	 */
	protected $form = null;

	/**
	 * Text to return for valid elements.
	 *
	 * @var string
	 */
	protected $isValidText = null;

	/**
	 * CSS Class to include in attribHTML.
	 *
	 * @var string
	 */
	protected $class = null;

	/**
	 * Flags if to hide the element when rendering the form.
	 *
	 * @var bool
	 */
	protected $hidden = false;

	/**
	 * HTML5 Placeholder.
	 *
	 * @var string
	 */
	protected $placeholder = null;

	/**
	 * Optional autocomplete parameter
	 *
	 * @var string
	 */
	protected $autocomplete = null;

	/**
	 * JavaScript required by the element.
	 *
	 * @var string
	 */
	protected $js = '';

	/**
	 * Optional data attributes.
	 *
	 * @var array
	 */
	protected $data = array();

	/**
	 *	Construct the element.
	 *
	 *	@param flForm $form The form the element will be used with or null if not using flForm
	 *	@param string $name The name of the element.
	 *	@param string $default The default value of the element.
	 */
	function __construct($form, $name, $default = null) {
		$this->name = $name;
		$this->value = $default;
		if(($this->form = $form) !== null)
			$this->form->addElement($this);
	}

	/**
	 * Get the escaped value, HTML elements and the characters [ ] are escaped.
	 *
	 * @return string The escaped value.
	 */
	protected function _getValueEscaped() {
		return str_replace(array('[', ']'), array('&#91;', '&#93;'), fusionLib::htmlChars($this->value));
	}

	/**
	 * Hide the element to stop it being output as part of the form.
	 *
	 * @param bool $hide true to hide the element; else false.
	 * @return flFormElement This object.
	 */
	function hide($hide = true) {
		$this->hidden = $hide;
		return $this;
	}

	/**
	 * Test if the element is hidden.
	 *
	 * @return bool true if the element is hidden; else false.
	 */
	function isHidden() {
		return $this->hidden;
	}

	/**
	 * Set the value for a data attribute.
	 *
	 * @param string $name The attribute name.
	 * @param string $value The value.
	 */
	function setData($name, $value) {
		$this->data[$name] = 'data-' . $name . '="' . $value . '"';
	}

	/**
	 * Process the request data, normally called by the flForm::isSubmitted().
	 * Validation of the data is also performed and the results stored.
	 *
	 * @param flRequest $request The request object.
	 */
	function processRequest($request) {
		$this->isSubmitted = true;

		// If readonly/disabled then ignore data from user
		if($this->readonly || $this->disabled)
			return;

		// Get the value and do basic cleaning on value (remove null chars and check UTF-8)
		$value = $request->getParam($this->name);
		if($value !== null) {
			// If array then do all elements
			if(is_array($value)) {
				$vals = array();
				foreach($value as $v) {
					// Ignore nested arrays
					if(!is_array($v)) {
						// Remove Null Characters
						$v = str_replace("\0", '', $v);

						// Save value if valid UTF-8 string
						if(flUtf8::isValid($v))
							$vals[] = $v;
					}
				}
				$value = $vals;
			}
			// Just a value
			else {
				// Remove Null Characters
				$value = str_replace("\0", '', $value);

				// Force the default if input isn't a valid UTF-8 string
				if(!flUtf8::isValid($this->value))
					$value = $this->value;
			}

			$this->value = $value;
		}

		// Test the data against each available validator and stop once one fails
		foreach($this->validators as $v) {
			if(!$v->validate($this)) {
				$this->error = $v->getMessage();
				$this->isValid = false;
				break;
			}
		}
	}

	/**
	 * Get the name of the element.
	 *
	 * @return string The name.
	 */
	function getName() {
		return $this->name;
	}

	/**
	 * Set a CSS Class to include in the element.
	 *
	 * @param string $class The CSS class to include.
	 * @return flFormElement Ths object.
	 */
	function setCSS($class) {
		$this->class = $class;
		return $this;
	}

	/**
	 * Set the HTML5 placeholder text for the element.
	 *
	 * @param string $placeholder The placeholder text for the element.
	 * @return flFormElement This object.
	 */
	function setPlaceholder($placeholder) {
		$this->placeholder = $placeholder;
		return $this;
	}

	/**
	 * Set the autocomplete value for the element.
	 *
	 * @param string $autocomplete The autocomplete value.
	 * @return flFormElement This object.
	 */
	function setAutocomplete($autocomplete) {
		$this->autocomplete = $autocomplete;
		return $this;
	}

	/**
	 * Get the current placeholder text.
	 *
	 * @return string The placeholder text of null if not set.
	 */
	function getPlaceholder() {
		return $this->placeholder;
	}

	/**
	 * Set the label for the element.
	 *
	 * @param string $label The label for the element.
	 * @return flFormElement This object.
	 */
	function setLabel($label) {
		$this->label = $label;
		return $this;
	}

	/**
	 * Get the label.
	 *
	 * @return string The elements label.
	 */
	function getLabel() {
		return $this->label;
	}

	/**
	 * Set the title for the element.
	 *
	 * @param string $title The title of the element.
	 * @return flFormElement This object.
	 */
	function setTitle($title) {
		$this->title = $title;
		return $this;
	}

	/**
	 * Get the title.
	 *
	 * @return string The elements title.
	 */
	function getTitle() {
		return $this->title;
	}

	/**
	 *	Set the value.
	 *
	 *	@param string $value The value.
	 *	@return flFormElement This object.
	 */
	function setValue($value) {
		$this->value = $value;
		return $this;
	}

	/**
	 * Get the value of the element.
	 *
	 * @return string The value.
	 */
	function getValue() {
		return $this->value;
	}

	/**
	 * Clean the current value removing XSS issues where possible.
	 *
	 * This method only has value if HTML is being preserved otherwise passing the
	 * value to fusionLib::htmlChars is quicker.
	 *
	 * See http://ha.ckers.org/xss.html for test cases.
	 *
	 * @param array $tags Array of tags to remove from the data being cleaned, default is:
	 * array('xml', 'html', 'body', 'meta', 'title', 'base', 'applet', 'script', 'embed', 'object',
	 * 'ilayer', 'layer', 'link', 'style', 'blink', 'iframe', 'frame', 'frameset', 'bgsound')
	 * @return string The cleaned value or null if there's a mismatch between open and closed tags.
	 */
	function getCleanedValue($tags = null) {

		$text = $this->value;
		if(!is_string($text))
			return '';

		// Protect &amp; &lt; &gt; from decoding
		$text = str_replace(array('&amp;', '&lt;', '&gt;'), array('&amp;amp;', '&amp;lt;', '&amp;gt;'), $text);

		// Clean up entities with white space before the closing ;
		$text = preg_replace('#(&\#*\w+)[\x00-\x20]+;#U', "$1;", $text);

		// Clean up entities with no ;
		$text = preg_replace('#(&\#x*)([0-9A-F]+);*#iU', "$1$2;", $text);

		// Decode entities
		$text = html_entity_decode($text, ENT_COMPAT, 'UTF-8');

		// List of tags not allowed
		if($tags === null)
			$badTags = array('xml', 'html', 'body', 'meta', 'title', 'base', 'applet', 'script', 'embed', 'object', 'ilayer', 'layer', 'link', 'style', 'blink', 'iframe', 'frame', 'frameset', 'bgsound');
		else
			$badTags = $tags;

		$counters = array();

		// Remove comments from the source
		$text = preg_replace('/<!--.*-->/sU', '', $text);

		// Break out tags
		$text = str_replace(array('//<![CDATA[', '//]]>'), array('', ''), $text);
		$text = preg_replace('/<!\[CDATA\[.*\]\]>/sU', '', $text);
		$parts = preg_split('!(<\s*/?\s*[^>]+>)!U', $text, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);

		// For each part filter it
		$text = '';
		foreach($parts as $bit) {

			// If tag
			$r = array();
			if(preg_match('!^<\s*(/)?\s*([a-z1-6]+)(\s+[^>]*)?\s*>$!i', $bit, $r)) {

				// Lowercase tag
				$r[2] = strtolower($r[2]);

				// If bad tag the HTML encode it and continue
				if(in_array($r[2], $badTags)) {
					$text .= fusionLib::htmlChars($bit);
				}
				else {
					// Count open/close
					if(!isset($counters[$r[2]]))
						$counters[$r[2]] = 0;

					if($r[1] == '/')
						$counters[$r[2]]--;
					else
						$counters[$r[2]]++;

					// If self closing tag ( /> )
					if(preg_match('#/\s*>#', $bit))
						$counters[$r[2]]--;

					// Clean attributes
					if(isset($r[3])) {

						// Remove comments from the source
						$r[3] = preg_replace('#/\*.*\*/#sU', '', $r[3]);

						// Remove JavaScript
						$r[3] = preg_replace('/j[\x00-\x20]*a[\x00-\x20]*v[\x00-\x20]*a[\x00-\x20]*s[\x00-\x20]*c[\x00-\x20]*r[\x00-\x20]*i[\x00-\x20]*p[\x00-\x20]*t[\x00-\x20]*:/i', '', $r[3]);

						// Remove VBScript
						$r[3] = preg_replace('/v[\x00-\x20]*b[\x00-\x20]*s[\x00-\x20]*c[\x00-\x20]*r[\x00-\x20]*i[\x00-\x20]*p[\x00-\x20]*t[\x00-\x20]*:/i', '', $r[3]);

						// Remove behavior
						$r[3] = preg_replace('/b[\x00-\x20]*e[\x00-\x20]*h[\x00-\x20]*a[\x00-\x20]*v[\x00-\x20]*i[\x00-\x20]*o[\x00-\x20]*r[\x00-\x20]*:/i', '', $r[3]);

						// Remove script
						$r[3] = preg_replace('/s[\x00-\x20]*c[\x00-\x20]*r[\x00-\x20]*i[\x00-\x20]*p[\x00-\x20]*t[\x00-\x20]*:/i', '', $r[3]);

						// Remove expression
						$r[3] = preg_replace('/e[\x00-\x20]*x[\x00-\x20]*p[\x00-\x20]*r[\x00-\x20]*e[\x00-\x20]*s[\x00-\x20]*s[\x00-\x20]*i[\x00-\x20]*o[\x00-\x20]*n[\x00-\x20]*\(/i', '(', $r[3]);

						// Revmoe on* handlers, strips everything after the handler
						 $r[3] = preg_replace('/\s+on[a-z]+\s*=[^>\/]*/i', '', $r[3]);
					}
					else
						$r[3] ='';

					// Remake tag
					$text .= "<$r[1]$r[2]$r[3]>";
				}
			}
			// Else text
			else {
				// Decode &, < and > to avoid double encoding
				$text .= fusionLib::htmlChars(str_replace(array('&amp;', '&lt;', '&gt;'), array('&', '<', '>'), $bit));
			}
		}

		// Check for unmatched tags
		foreach($counters as $c) {
			if($c)
				return null;
		}

		return $text;
	}

	/**
	 * Set the element readonly state.
	 *
	 * @param bool $readonly true to make the element readonly.
	 * @return flFormElement This object.
	 */
	function setReadonly($readonly = true) {
		$this->readonly = $readonly;
		return $this;
	}

	/**
	 * Get if the element is readonly.
	 *
	 * @return bool true if the element is readonly.
	 */
	function isReadonly() {
		return $this->readonly;
	}

	/**
	 * Set the element disabled state.
	 *
	 * @param bool $disabled true to make the element disabled.
	 * @return flFormElement This object.
	 */
	function setDisabled($disabled = true) {
		$this->disabled = $disabled;
		return $this;
	}

	/**
	 * Get if the element is disabled.
	 *
	 * @return bool true if the element is disabled.
	 */
	function isDisabled() {
		return $this->disabled;
	}

	/**
	 * Set the element required state.
	 *
	 * @param bool $required true if the element is required.
	 * @return flFormElement This object.
	 */
	function setRequired($required = true) {
		$this->required = $required;
		return $this;
	}

	/**
	 * Get if the element is required.
	 *
	 * @return bool true if the element is required.
	 */
	function isRequired() {
		return $this->required;
	}

	/**
	 * Set the text to return with getError() for valid elements if not the form default.
	 *
	 * @param string $text The text to set.
	 */
	function setValidText($text) {
		$this->isValidText = $text;
	}

	/**
	 * Get the error message from form validation.
	 *
	 * @return string The error message or the system wide valid text message or
	 * an empty string of validation has not yet been performed.
	 */
	function getError() {
		return !$this->isSubmitted ? '' : ($this->error === null ? ($this->isValidText !== null ? $this->isValidText : ($this->form !== null ? $this->form->getValidText() : 'Looks Alright!')) : $this->error);
	}

	/**
	 * Set a description for the element.
	 *
	 * @param string $description The description.
	 * @return flFormElement This object.
	 */
	function setDescription($description) {
		$this->description = $description;
		return $this;
	}

	/**
	 * Get the elements description.
	 *
	 * @return string The description or null if none set.
	 */
	function getDescription() {
		return $this->description;
	}

	/**
	 * Add a validator to the element.
	 *
	 * @param flFormValidator $validator The validator object.
	 * @return flFormElement This object.
	 */
	function addValidator($validator) {
		$this->validators[] = $validator;
		return $this;
	}

	/**
	 * Test if the element has validators.
	 *
	 * @return bool true if the element has validators; else false.
	 */
	function hasValidators() {
		return count($this->validators) != 0;
	}

	/**
	 * Test if the element input is valid.
	 *
	 * @return bool true if the element is valid or isSubmitted() returns false, false if it is not valid.
	 */
	function isValid() {
		return $this->isValid;
	}

	/**
	 * Test if data has been submitted to the element.
	 *
	 * @return bool true if data has been submitted; else false.
	 */
	function isSubmitted() {
		return $this->isSubmitted;
	}

	/**
	 * Draw the element.
	 *
	 * @return string The HTML.
	 */
	abstract function render();

	/**
	 * Get the JavaScript for the element if any.
	 *
	 * @return string The string holding the JavaScript for the element.
	 */
	function getJavaScript() {
		return $this->js;
	}

	/**
	 * Add JavaScript to the element.
	 *
	 * @param string $js JavaScript to include with element.
	 * @return flFormElement This element.
	 */
	function setJavaScript($js) {
		$this->js = $js;
		return $this;
	}

	/**
	 * Get the name of the element for use in JS validation rules.
	 *
	 * @return string The element name for use in a client rule.
	 */
	protected function _getClientRuleName() {
		return $this->name;
	}

	/**
	 * Get the client side validation rules if any.
	 *
	 * @return string The client side validation rules for use with $fl.attachValidation
	 */
	function getClientRules() {
		$ajax = false;
		$cb = '';
		$rules = array();
		foreach($this->validators as $v) {
			if(($rule = $v->getClientRule()) !== false) {
				list($r, $a) = $rule;
				$ajax = $ajax || $a;
				if($v->getAJAXCallback() !== null && empty($cb))
					$cb = 'callback: "' . $v->getAJAXCallback() . '"';
				if(!empty($r))
					$rules[] = $r;
			}
		}

		// If one or more rules needs ajax
		if($ajax) $rules[] = "ajax: { $cb }";

		// Combine all the individual rules into 1 rule for the element
		$s = '';
		if(count($rules))
			$s = "'" . $this->_getClientRuleName() . "': { " . join(", ", $rules) . " }";

		return $s;
	}

	/**
	 * Get the text to show for valid elements during client side validation.
	 *
	 * @return string The client side validation message for valid elements.
	 */
	function getValidText() {
		return $this->isValidText === null ? null : "'" . $this->_getClientRuleName() . "': '" . str_replace("'", "\'", $this->isValidText) . "'";
	}

	/**
	 * Generate the HTML for common attributes.
	 *
	 * @return string HTML attributes.
	 */
	protected function attribHTML() {
		$html = '';
		$isValid = $this->isValid() ? '' : ' validationError';

		if($this->readonly) $html .= ' readonly="readonly"';
		if($this->disabled) $html .= ' disabled="disabled"';
		if($this->label) $html .= ' aria-labelledby="' . $this->name . '-label"';
		if($this->title) $html .= ' title="' . $this->title . '"';
		if($this->description !== null) $html .= ' aria-describedby="' . $this->name . '-desc"';
		if($this->required) $html .= ' aria-required="true"';
		if($this->class !== null)
			$html .= ' class="' . $this->class . $isValid . '"';
		else if(!empty($this->class))
			$html .= ' class="' . $isValid . '"';
		if($this->placeholder !== null) $html .= ' placeholder="' . $this->placeholder . '"';
		if($this->autocomplete !== null) $html .= ' autocomplete="' . $this->autocomplete . '"';

		if(count($this->data))
			$html .= ' ' . join(' ', $this->data);

		return $html;
	}

	/**
	 * Generate the HTML for the elements label.
	 *
	 * @return string The label HTML.
	 */
	function getLabelHTML() {

		$label = $this->label === null ? '&nbsp;' : $this->label;

		$r = "<label id=\"{$this->name}-label\" for=\"{$this->name}\" aria-atomic=\"true\" aria-live=\"polite\"" . ($this->isSubmitted() && !$this->isValid() ? ' class="validationError"' : '') . "><span>$label";
		if($this->required) $r .= ' <em>' . ($this->form !== null ? $this->form->getRequiredText() : '(Required)') . '</em>';

		if($this->isSubmitted()) {
			$cls = $this->isValid() ? 'ok' : 'failed';

			// Optionally hide generic valid messages
			if($cls == 'ok' && $this->isValidText === null && $this->form !== null && $this->form->getShowOnlyErrors()) {
				$cls = 'hide';
				$msg = '&nbsp;';
			}
			else
				$msg = $this->getError();
		}
		else {
			$cls = '';
			$msg = '&nbsp;';
		}

		return $r . "</span> <span class=\"validation $cls\" id=\"{$this->name}-validation\"><strong>$msg</strong></span></label>";
	}

	/**
	 *	Generate the HTML for the description if any.
	 *
	 *	@return string The description HTML.
	 */
	function getDescriptionHTML() {
		return $this->description !== null ? "<div class=\"description\" id=\"{$this->name}-desc\">{$this->description}</div>\n" : '';
	}

	/**
	 * Force the element to be invalid with an error message.
	 *
	 * @param string $message The error message to show on the element.
	 */
	function forceError($message) {
		$this->error = $message;
		$this->isValid = false;
	}
}
