<?php

//--------------------------------------------------
// Support functions

	function request($variable, $method = 'REQUEST') {

		//--------------------------------------------------
		// Get value

			$value = NULL;
			$method = strtoupper($method);

			if ($method == 'POST') {

				if (isset($_POST[$variable])) {
					$value = $_POST[$variable];
				}

			} else if ($method == 'REQUEST') {

				if (isset($_REQUEST[$variable])) {
					$value = $_REQUEST[$variable];
				}

			} else {

				if (isset($_GET[$variable])) {
					$value = $_GET[$variable];
				}

			}

		//--------------------------------------------------
		// Strip slashes (IF NESS)

			if ($value !== NULL && ini_get('magic_quotes_gpc')) {
				$value = strip_slashes_deep($value);
			}

		//--------------------------------------------------
		// Return value

			return $value;

	}

	function exit_with_error($message, $hidden_info = NULL) {
		exitWithError($message, $hidden_info);
	}

	function tmp_folder($name) {
		$tmp_folder = sys_get_temp_dir() . '/php-upload/';
		if (!is_dir($tmp_folder)) {
			mkdir($tmp_folder, 0777, true);
		}
		return $tmp_folder;
	}

	// function html($text) {
	// 	return htmlspecialchars($text, ENT_QUOTES, config::get('output.charset')); // htmlentities does not work for HTML5+XML
	// }

	function html_decode($html) {
		return html_entity_decode($html, (ENT_QUOTES | ENT_HTML5), config::get('output.charset'));
	}

	function html_tag($tag, $attributes) {
		$html = '<' . html($tag);
		foreach ($attributes as $name => $value) {
			if ($value !== NULL) { // Allow numerical value 0 and empty string ""
				$name = (is_int($name) ? $value : $name);
				if ($name == 'placeholder') {
					$html .= ' ' . html($name) . '="' . preg_replace('/\r?\n/', '&#10;', html($value)) . '"'; // Support multi-line placeholder on textarea (not all attributes, as it's a slow RegExp, and Safari 11.1 does not support this).
				} else {
					$html .= ' ' . html($name) . '="' . html($value) . '"';
				}
			}
		}
		return new html_safe_value($html . ($tag == 'input' || $tag == 'link' ? ' />' : '>')); // Not ideal, as some attributes are not safe (e.g. <a href="?">)
	}

	function human_to_ref($text) {

		$text = strtolower($text);
		$text = preg_replace('/[^a-z0-9]/i', ' ', $text); // TODO: Allow hyphens, so the radio field "-1" does not get changed to "1", and the URL "/a/sub-page/" gets the ID "#p_a_sub-page".
		$text = preg_replace('/ +/', '_', trim($text));

		return $text;

	}

	function format_bytes($size, $precision = 0, $units = NULL) { // like format_currency(), format_postcode(), format_telephone_number() ... and number_format(), date_format(), money_format()

		$separator = '';

		if (!is_array($units)) {
			if (is_string($units)) {
				$separator = $units;
			}
			$units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
		}

		$last_unit = end($units);

		foreach ($units as $unit) {
			if ($size >= 1024 && $unit != $last_unit) {
				$size = ($size / 1024);
			} else {
				return round($size, $precision) . $separator . $unit;
			}
		}

	}

	function parse_bytes($size) { // Like parse_number() ... and parse_url(), parse_str(), date_parse(), xml_parse() - inspired by the function get_real_size(), from Moodle (http://moodle.org) by Martin Dougiamas

		$size = trim($size);

		if (strtoupper(substr($size, -1)) == 'B') {
			$size = substr($size, 0, -1); // Drop the B, as in 10B or 10KB
		}

		$units = array(
				'P' => 1125899906842624,
				'T' => 1099511627776,
				'G' => 1073741824,
				'M' => 1048576,
				'K' => 1024,
			);

		$unit = strtoupper(substr($size, -1));
		if (isset($units[$unit])) {
			$size = (substr($size, 0, -1) * $units[$unit]);
		}

		return intval($size);

	}

	function is_email($email, $domain_check = true) {

	$format_valid = preg_match('/^(?:[a-z0-9\.!#$%&\'\*\+\/=?^_`{|}~-]+|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(\w[-._\w]*\.[a-zA-Z]{2,})$/i', $email, $matches);

		// The RegExp used to:
		// - End '{2,}.*)$', not sure why it had '.*' at the end, as it allowed 'example@example.com extra'
		// - Start '\w[-=.+\'\w]*@', but got too restrictive (missing #), so now following RFC 5322 (emailregex.com), but keeping the simplistic domain matching bit (don't want IP addresses).
		//
		// Also vaguely following:
		//   https://html.spec.whatwg.org/multipage/input.html#email-state-(type=email)
		//   /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/

	if ($format_valid) {

		if ($domain_check === false || config::get('email.check_domain', true) === false || !function_exists('checkdnsrr')) {
			return true;
		}

		foreach (array('MX', 'A') as $type) {
			$start = hrtime(true);
			$valid = checkdnsrr($matches[1] . '.', $type);
			if (function_exists('debug_log_time')) {
				debug_log_time('DNS' . $type, round(hrtime_diff($start), 3));
			}
			if ($valid) {

	function is_assoc($array) {
		return (count(array_filter(array_keys($array), 'is_string')) > 0); // https://stackoverflow.com/q/173400
	}

	function format_postcode($postcode, $country = 'UK') {

		if ($country == 'UK') {

			// UK: https://en.wikipedia.org/wiki/UK_postcodes
			// A9 9AA | A99 9AA | AA9 9AA | AA99 9AA | A9A 9AA | AA9A 9AA | BFPO 99

			$postcode = preg_replace('/[^A-Z0-9]/', '', strtoupper(strval($postcode)));

			if (preg_match('/^([A-Z](?:\d[A-Z\d]?|[A-Z]\d[A-Z\d]?))(\d[A-Z]{2})$/', $postcode, $matches)) {

				return $matches[1] . ' ' . $matches[2];

			} else if (preg_match('/^(BFPO) *([0-9]+)$/', $postcode, $matches)) { // British forces post office

				return $matches[1] . ' ' . $matches[2];

			} else {

				return NULL;

			}

		} else {

			return $postcode; // Unknown country, don't change

		}

	}

	function format_currency($value, $currency_char = NULL, $decimal_places = 2, $zero_to_blank = false) {

		if ($currency_char === NULL) {
			$currency_char = config::get('output.currency_char', '£');
		}

		if ($value === NULL) {
			return NULL;
		}

		if ($decimal_places === 'auto') {
			$decimal_places = (fmod($value, 1) == 0 ? 0 : 2);
		}

		$value = floatval($value);
		$value = (round($value, $decimal_places) == 0 ? 0 : $value); // Stop negative -£0

		if ($value == 0 && $zero_to_blank) {
			return '';
		} else if ($value < 0) {
			return '-' . $currency_char . number_format((0 - $value), $decimal_places);
		} else {
			return $currency_char . number_format($value, $decimal_places);
		}

	}

	function parse_number($value) {
		if (!is_float($value) && !is_int($value) && $value !== NULL) {

			$value = preg_replace('/^[^0-9\.\-]*(-?)[^0-9\.]*(.*?)[^0-9\.]*$/', '$1$2', $value); // Strip prefix/suffix invalid characters (e.g. currency symbol)

			$pos = strrpos($value, ',');
			if ($pos !== false && (strlen($value) - $pos) > 3) { // Strip the thousand separators, but don't convert the European "5,00" to "500"
				$value = str_replace(',', '', $value);
			}

			if (!preg_match('/^\-?[0-9]*(\.[0-9]{0,})?$/', $value)) { // Also allowing '.3' to become 0.3
				return NULL; // Invalid number
			} else {
				$value = floatval($value);
			}

		}
		return $value;
	}

	function prefix_match($prefix, $string) {
		if (PHP_VERSION_ID >= 80000) {
			trigger_error('Please replace prefix_match(), as PHP 8 provides str_starts_with(); but please note, the arguments are reversed.', E_USER_NOTICE);
		}
		return (strncmp($string, $prefix, strlen($prefix)) === 0);
	}

//--------------------------------------------------
// Config object

	class config {

		//--------------------------------------------------
		// Variables

			private $store = [];
			private $encrypted = [];

		//--------------------------------------------------
		// Set and get

			public static function set($variable, $value = NULL, $encrypted = false) {
				$obj = config::instance_get();
				if (is_array($variable) && $value === NULL) {
					$obj->store = array_merge($obj->store, $variable);
					$obj->encrypted = array_merge($obj->encrypted, array_fill_keys(array_keys($obj->encrypted), false));
				} else {
					$obj->store[$variable] = $value;
					$obj->encrypted[$variable] = $encrypted;
				}
			}

			public static function set_default($variable, $value, $encrypted = false) {
				$obj = config::instance_get();
				if (!array_key_exists($variable, $obj->store)) { // Can be set to NULL
					$obj->store[$variable] = $value;
					$obj->encrypted[$variable] = $encrypted;
				}
			}

			public static function set_all($variables) { // Only really used once, during setup
				$obj = config::instance_get();
				$obj->store = $variables;
				$obj->encrypted = array_fill_keys(array_keys($variables), false);
			}

			public static function get($variable, $default = NULL) {
				$obj = config::instance_get();
				if (array_key_exists($variable, $obj->store)) {
					return $obj->store[$variable];
				} else {
					return $default;
				}
			}

			public static function get_all($prefix = '', $encrypted_mask = NULL) {
				$obj = config::instance_get();
				$prefix .= '.';
				$prefix_length = strlen($prefix);
				if ($prefix_length <= 1) {
					$data = $obj->store;
					if ($encrypted_mask) {
						$data = array_merge($data, array_fill_keys(array_keys($obj->encrypted, true), $encrypted_mask));
					}
					return $data;
				} else {
					$data = [];
					foreach ($obj->store as $k => $v) {
						if (substr($k, 0, $prefix_length) == $prefix) {
							$data[substr($k, $prefix_length)] = $v;
						}
					}
					return $data;
				}
			}

			public static function get_encrypted($value) { // TODO [secrets-cleanup] - Use secrets::get() or secrets::key_get() instead
				$key = getenv('PRIME_CONFIG_KEY');
				if (!$key) {
					throw new error_exception('Missing environment variable "PRIME_CONFIG_KEY"');
				}
				return encryption::encode($value, $key);
			}

			public static function get_decrypted($variable, $default = NULL) { // TODO [secrets-cleanup] - Use secrets::get() or secrets::key_get() instead
				$obj = config::instance_get();
				if (array_key_exists($variable, $obj->store)) {
					if (isset($obj->encrypted[$variable]) && $obj->encrypted[$variable]) {
						return config::value_decrypt($obj->store[$variable]);
					} else {
						return $obj->store[$variable];
					}
				} else {
					return $default;
				}
			}

			public static function value_decrypt($value) { // TODO [secrets-cleanup] - Use secrets::get() or secrets::key_get() instead
				$key = getenv('PRIME_CONFIG_KEY');
				if (!$key) {
					throw new error_exception('Missing environment variable "PRIME_CONFIG_KEY"');
				}
				return encryption::decode($value, $key);
			}

		//--------------------------------------------------
		// Array support

			public static function array_push($variable, $value) {
				$obj = config::instance_get();
				if (!isset($obj->store[$variable]) || !is_array($obj->store[$variable])) {
					$obj->store[$variable] = [];
				}
				$obj->store[$variable][] = $value;
			}

			public static function array_set($variable, $key, $value) {

					// e.g.
					//  config::array_set('example', 'A', 1);
					//  config::array_set('example', 'B', 2);
					//  config::array_set('example', 'C', 'D', 3); <-- Walking further into the array

				$obj = config::instance_get();

				$args = func_get_args();
				$count = (count($args) - 1); // Last arg is the $value

				$ref = &$obj->store;
				for ($k = 0; $k < $count; $k++) {
					$key = $args[$k];
					if (!isset($ref[$key]) || !is_array($ref[$key])) {
						$ref[$key] = [];
					}
					$ref = &$ref[$key];
				}

				$ref = $args[$count];

			}

			public static function array_get($variable, $key, $default = NULL) {
				$obj = config::instance_get();
				if (isset($obj->store[$variable][$key])) {
					return $obj->store[$variable][$key];
				} else {
					return $default;
				}
			}

			public static function array_search($variable, $value) {
				$obj = config::instance_get();
				if (isset($obj->store[$variable]) && is_array($obj->store[$variable])) {
					return array_search($value, $obj->store[$variable]);
				}
				return false;
			}

		//--------------------------------------------------
		// Singleton

			private static function instance_get() {
				static $instance = NULL;
				if (!$instance) {
					$instance = new config();
				}
				return $instance;
			}

			final private function __construct() {
				// Being private prevents direct creation of object, which also prevents use of clone.
			}

	}
	config::set('output.charset', $GLOBALS['pageCharset']);
	config::set('request.uri', $GLOBALS['tplPageUrl']);
	config::set('request.url', $GLOBALS['tplHttpsUrl']);
	config::set('request.method', (isset($_SERVER['REQUEST_METHOD']) ? strtoupper($_SERVER['REQUEST_METHOD']) : 'GET'));
	config::set('request.referrer', str_replace($GLOBALS['webDomainSSL'], '', (isset($_SERVER['HTTP_REFERER']) ? $_SERVER['HTTP_REFERER'] : '')));

//--------------------------------------------------
// Database object

	class db extends database {
		public function __construct() {
			$this->link = $GLOBALS['db']->link;
		}
		public function fetch_row($result = null) {
			return $this->fetchAssoc($result);
		}
		public function insert_id() {
			return $this->insertId();
		}
		public function affected_rows() {
			return $this->affectedRows();
		}
		public function enum_values($table_sql, $field) {
			return $this->enumValues($table_sql, $field);
		}
	}

//--------------------------------------------------
// Form objects

//--------------------------------------------------
// http://www.phpprime.com/doc/helpers/form/
//--------------------------------------------------

	class form {

		//--------------------------------------------------
		// Variables

			private $form_id = NULL;
			private $form_action = './';
			private $form_method = 'POST';
			private $form_class = '';
			private $form_button = 'Save';
			private $form_button_name = 'button';
			private $form_attributes = [];
			private $form_passive = false;
			private $form_submitted = false;
			private $autocomplete = NULL;
			private $autocomplete_default = NULL;
			private $disabled = false;
			private $readonly = false;
			private $autofocus = false;
			private $autofocus_submit = false;
			private $wrapper_tag = 'fieldset';
			private $print_page_setup = NULL; // Current page being setup in code.
			private $print_page_submit = NULL; // Current page the user submitted.
			private $print_page_skipped = false;
			private $print_page_valid = true;
			private $print_group = NULL;
			private $print_group_tag = 'h2';
			private $print_group_class = NULL;
			private $hidden_values = [];
			private $fields = [];
			private $field_refs = [];
			private $field_count = 0;
			private $field_tag_id = 0;
			private $file_setup_complete = false;
			private $required_mark_html = NULL;
			private $required_mark_position = 'left';
			private $label_prefix_html = '';
			private $label_suffix_html = ':';
			private $label_override_function = NULL;
			private $errors_html = [];
			private $error_override_function = NULL;
			private $post_validation_done = false;
			private $db_link = NULL;
			private $db_record = NULL;
			private $db_table_name_sql = NULL;
			private $db_table_alias_sql = NULL;
			private $db_where_sql = NULL;
			private $db_where_parameters = NULL;
			private $db_log_table = NULL;
			private $db_log_values = [];
			private $db_fields = NULL;
			private $db_values = [];
			private $db_save_disabled = false;
			private $dedupe_user_id = NULL; // Protects against the form being submitted multiple times (typically on a slow internet connection)
			private $dedupe_ref = NULL;
			private $dedupe_data = NULL;
			private $saved_values_data = NULL;
			private $saved_values_used = NULL;
			private $saved_values_ignore = NULL;
			private $saved_message_html = 'Please submit this form again.';
			private $csrf_token = NULL;
			private $csrf_error_html = 'The request did not appear to come from a trusted source, please try again.';

			private $fetch_allowed = [
					'dest' => ['document'],
					'mode' => ['navigate'],
					'site' => ['same-origin', 'none'], // 'none' because a user can POST the form, see errors, and refresh the page.
					'user' => ['?1', '?T'],
				];

			private $fetch_known = [
					'dest' => ['audio', 'audioworklet', 'document', 'embed', 'empty', 'font', 'image', 'manifest', 'object', 'paintworklet', 'report', 'script', 'serviceworker', 'sharedworker', 'style', 'track', 'video', 'worker', 'xslt', 'nested-document'],
					'mode' => ['cors', 'navigate', 'nested-navigate', 'no-cors', 'same-origin', 'websocket'],
					'site' => ['cross-site', 'same-origin', 'same-site', 'none'],
					'user' => ['?0', '?1', '?F', '?T'], // In HTTP/1 headers, a boolean is indicated with a leading "?"
				];

		//--------------------------------------------------
		// Setup

			public function __construct() { // Do not have any arguments for init, as objects like order_form() need to set their own.
				$this->setup();
			}

			final protected function __clone() {
				trigger_error('Clone of form object is not allowed.', E_USER_ERROR); // You would need a different form_id (to distinguish which form is submitted), but now fields defined on form 1 won't won't collect their POST values (form 1 was "not submitted"), and errors generated on form 1 will be shown on both forms (irrespective of which form was submitted).
			}

			protected function setup() {

				//--------------------------------------------------
				// Site config

					$site_config = config::get_all('form');

				//--------------------------------------------------
				// Defaults

					$this->form_action = config::get('request.url'); // Use the full domain name so a malicious "base" meta tag does not send data to a different website (so not request.uri).

					if (isset($site_config['disabled'])) $this->disabled = ($site_config['disabled'] == true);
					if (isset($site_config['readonly'])) $this->readonly = ($site_config['readonly'] == true);

					if (isset($site_config['dedupe_user_id'])) $this->dedupe_user_id = $site_config['dedupe_user_id'];

					if (isset($site_config['label_override_function'])) $this->label_override_function = $site_config['label_override_function'];
					if (isset($site_config['error_override_function'])) $this->error_override_function = $site_config['error_override_function'];

				//--------------------------------------------------
				// Internal form ID

					$form_id = (isset($site_config['count']) ? $site_config['count'] : 1);

					config::set('form.count', ($form_id + 1));

					$this->form_id_set('form_' . $form_id);

				//--------------------------------------------------
				// CSRF setup (set cookie)

					$this->csrf_token = csrf_token_get();

				//--------------------------------------------------
				// Fetch limits

					$fetch_allowed = config::get('form.fetch_allowed', NULL);
					if (is_array($fetch_allowed)) {

						if (in_array('nested', $fetch_allowed)) $this->fetch_allowed_nested();
						if (in_array('fetch',  $fetch_allowed)) $this->fetch_allowed_fetch();
						if (in_array('cors',   $fetch_allowed)) $this->fetch_allowed_cors(); // Deprecated

					} else if ($fetch_allowed === false) {

						$this->fetch_allowed = false; // Disabled, not a good idea.

					}

				//--------------------------------------------------
				// Dest support

					if ($this->form_submitted) {

						$this->hidden_value('dest');

					} else {

						$dest = request('dest'); // Can be GET or POST

						if ($dest == 'referrer') {
							$referrer = config::get('request.referrer');
							if (substr($referrer, 0, 1) == '/') { // Must have a value, and must be for this site. Where a scheme-relative URL "//example.com" won't work, as the domain would be prefixed.
								$dest = url($referrer, array('dest' => NULL)); // If the previous page also had a "dest" value, drop it (stop loop)
							} else {
								$dest = NULL; // Not provided, e.g. user re-loaded page
							}
						} else if (substr(strval($dest), 0, 3) == '%2F') {
							$dest = urldecode($dest); // In a 'passive' form, the form isn't 'submitted', so hidden_value() isn't used, and the URL-encoded value (needed to overcome limits on hidden input fields), is not decoded.
						}

						$this->hidden_value_set('dest', $dest);

					}

			}

			public function form_id_set($form_id) {
				$this->form_id = $form_id;
				$this->_is_submitted();
			}

			public function form_id_get() {
				return $this->form_id;
			}

			public function form_action_set($form_action) {
				if ($form_action instanceof url) {
					$form_action->format_set('full'); // We use full URL's, see comment about 'request.url' above.
				}
				$this->form_action = $form_action;
			}

			public function form_action_get() {
				return $this->form_action;
			}

			public function form_method_set($form_method) {
				$this->form_method = strtoupper($form_method);
				$this->_is_submitted();
			}

			public function form_method_get() {
				return $this->form_method;
			}

			public function form_class_set($form_class) {
				$this->form_class = $form_class;
			}

			public function form_class_add($class) {
				$this->form_class .= ($this->form_class == '' ? '' : ' ') . $class;
			}

			public function form_class_get() {
				return $this->form_class;
			}

			public function form_button_set($text = NULL) {
				$this->form_button = $text;
			}

			public function form_button_get() {
				return $this->form_button;
			}

			public function form_attribute_set($attribute, $value) {
				if ($value === NULL) {
					unset($this->form_attributes[$attribute]);
				} else {
					$this->form_attributes[$attribute] = $value;
				}
			}

			public function form_button_name($name) {
				$this->form_button_name = $name;
			}

			public function form_passive_set($passive, $method = NULL) { // Always considered as "submitted" and uses "form.csrf_passive_checks"... good for a search form.
				$this->form_passive = ($passive == true);
				$this->form_button_name = ($this->form_passive ? NULL : 'button'); // As passive we don't need to know which button is pressed (just adds cruft to url)
				if ($method !== NULL) {
					$this->form_method_set($method);
				} else {
					$this->_is_submitted();
				}
			}

			public function form_passive_get() {
				return $this->form_passive;
			}

			public function form_autocomplete_set($autocomplete) {
				$this->autocomplete = $autocomplete;
			}

			public function form_autocomplete_get() {
				return $this->autocomplete;
			}

			public function autocomplete_default_set($autocomplete) {
				$this->autocomplete_default = $autocomplete;
			}

			public function autocomplete_default_get() {
				return $this->autocomplete_default;
			}

			public function disabled_set($disabled) {
				$this->disabled = ($disabled == true);
			}

			public function disabled_get() {
				return $this->disabled;
			}

			public function readonly_set($readonly) {
				$this->readonly = ($readonly == true);
			}

			public function readonly_get() {
				return $this->readonly;
			}

			public function autofocus_set($autofocus) {
				$this->autofocus = ($autofocus == true);
				$this->autofocus_submit = $this->autofocus;
			}

			public function autofocus_get() {
				return $this->autofocus;
			}

			public function wrapper_tag_set($tag) {
				$this->wrapper_tag = $tag;
			}

			public function print_page_start($page) {

				$page = intval($page);

				if (($this->print_page_setup + 1) != $page) { // also blocks adding fields to page 1 after starting page 2.
					exit_with_error('Missing call to form->print_page_start(' . ($this->print_page_setup + 1) . ') - must be sequential');
				}

				if ($this->print_page_valid !== true) {
					exit_with_error('Cannot call form->print_page_start(' . $page . ') without first checking form->valid()');
				}

				if ($this->print_page_setup === NULL) {

					if (count($this->fields) != 0) {
						exit_with_error('You must call form->print_page_start(1) before adding any fields.');
					}

					$this->print_page_submit = intval($this->hidden_value_get('page'));
					if ($this->print_page_submit == 0) {
						$this->print_page_submit = 1;
					}

				} else {

					foreach ($this->fields as $field) {
						$field->print_hidden_set(true);
					}

				}

				if ($page != 1 && !$this->submitted($page - 1)) {
					exit_with_error('Cannot call form->print_page_start(' . $page . ') without first checking form->submitted(' . ($page - 1) . ')');
				}

				$this->hidden_value_set('page', $page);

				$this->print_page_setup = $page;

			}

			public function print_page_skip($page) { // If there is an optional page, this implies that it has been submitted, before calling the next print_page_start()

				$this->print_page_skipped = true;

				if ($page == 1) {
					$this->form_submitted = true;
				}

				if ($this->print_page_submit >= $page) {
					return; // Already on or after this page.
				}

				if (($this->print_page_submit + 1) != $page) {
					exit_with_error('You must call form->print_page_skip(' . ($this->print_page_submit + 1) . ') before you can skip to page "' . $page . '"');
				}

				$this->print_page_submit = $page;

			}

			public function print_page_get() {
				return $this->print_page_setup;
			}

			public function print_group_start($print_group) {
				$this->print_group = $print_group;
			}

			public function print_group_get() {
				return $this->print_group;
			}

			public function print_group_tag_set($tag) {
				return $this->print_group_tag = $tag;
			}

			public function print_group_class_set($tag) {
				return $this->print_group_class = $tag;
			}

			public function hidden_value($name) { // You should call form->hidden_value() first to initialise - get/set may not be called when form is submitted with errors.
				if ($this->form_submitted) {
					$value = request($name, $this->form_method);
					$value = ($value === NULL ? NULL : urldecode($value));
				} else {
					$value = '';
				}
				$this->hidden_value_set($name, $value);
			}

			public function hidden_value_set($name, $value = NULL) {
				if (str_starts_with($name, 'h-')) {
					exit_with_error('Cannot set the hidden value "' . $name . '", as it begins with a "h-" (used by hidden fields).');
				}
				if ($value === NULL) {
					unset($this->hidden_values[$name]);
				} else {
					$this->hidden_values[$name] = $value;
				}
			}

			public function hidden_value_get($name) {
				if (isset($this->hidden_values[$name])) {
					return $this->hidden_values[$name];
				} else {
					if ($this->saved_values_available()) {
						$value = $this->saved_value_get($name);
					} else {
						$value = request($name, $this->form_method);
					}
					return ($value === NULL ? NULL : urldecode($value));
				}
			}

			public function dest_url_get() {
				return $this->hidden_value_get('dest');
			}

			public function dest_url_set($url) {
				return $this->hidden_value_set('dest', $url);
			}

			public function dest_redirect($default_url, $config = []) {
				$url = strval($this->dest_url_get());
				if (substr($url, 0, 1) != '/') { // Must have a value, and must be for this site. Where a scheme-relative URL "//example.com" won't work, as the domain would be prefixed.
					$url = $default_url;
				}
				$this->redirect($url, $config);
			}

			public function redirect($url, $config = []) {

				if ($this->dedupe_ref) {

					$now = new timestamp();

					$db = $this->db_get();

					$db->insert(DB_PREFIX . 'system_form_dedupe', [
							'user_id'         => $this->dedupe_user_id,
							'ref'             => $this->dedupe_ref,
							'data'            => $this->dedupe_data,
							'created'         => $now,
							'redirect_url'    => $url,
							'redirect_config' => json_encode($config),
						]);

					$sql = 'DELETE FROM
								' . DB_PREFIX . 'system_form_dedupe
							WHERE
								created < ?';

					$parameters = [];
					$parameters[] = $now->clone('-1 hour');

					$db->query($sql, $parameters);

				}

				redirect($url, $config);

			}

			public function human_check($label, $error, $config = []) {

				$field_human = NULL;
				$is_human = NULL;

				if ($this->form_submitted) {

					$config = array_merge([
							'content_values' => [],
							'db_field'       => NULL,
							'db_insert'      => false, // Still insert when found to be spam (for logging purposes); can also be an array e.g. ['deleted' => $now]
							'time_min'       => 5,
							'time_max'       => (60*60*3),
							'force_check'    => false,
						], $config);

					$content_values = $config['content_values'];
					foreach ($content_values as $key => $value) {
						if ($value instanceof form_field) {
							$content_values[$key] = $value->value_get();
						}
					}
					if (count($content_values) > 0) {
						$content_spam = is_spam_like(implode(' ', $content_values));
					} else {
						$content_spam = false;
					}

					$timestamp_original = request('o');
					if (preg_match('/^[0-9]{10,}$/', $timestamp_original)) {
						$timestamp_diff = (time() - $timestamp_original);
						$timestamp_spam = ($timestamp_diff < $config['time_min'] || $timestamp_diff > $config['time_max']);
					} else {
						$timestamp_spam = true; // Missing or invalid value
					}

					if ($content_spam || $timestamp_spam || $config['force_check']) {

						list($field_human, $is_human) = $this->human_check_field_get($label, $error);

						if ($config['db_field']) {
							$field_human->db_field_set($config['db_field']); // Set the field to enum('', 'true', 'false') where a blank value shows they did not see this field.
						}

						if (!$is_human && $config['db_field']) {
							if (is_array($config['db_insert'])) {
								foreach ($config['db_insert'] as $field => $value) {
									$this->db_value_set($field, $value);
								}
								$config['db_insert'] = true;
							}
							if ($config['db_insert'] === true) {
								$this->db_insert();
							}
						}

					}

				}

				return [$field_human, $is_human];

			}

			public function human_check_field_get($label, $error) {

				$day = floor(time() / (60*60*24));

				$field_human = new form_field_checkbox($this, $label, 'human_' . $day);
				$field_human->text_values_set('true', 'false');
				$field_human->required_error_set($error);

				$is_human = ($field_human->value_get() == 'true');

				return [$field_human, $is_human];

			}

			public function required_mark_set($required_mark) {
				$this->required_mark_set_html(to_safe_html($required_mark));
			}

			public function required_mark_set_html($required_mark_html) {
				$this->required_mark_html = $required_mark_html;
			}

			public function required_mark_get_html($required_mark_position = 'left') {
				if ($this->required_mark_html !== NULL) {
					return $this->required_mark_html;
				} else if (($required_mark_position === 'right') || ($required_mark_position === NULL && $this->required_mark_position === 'right')) {
					return '&#xA0;<abbr class="required" title="Required" aria-label="Required">*</abbr>';
				} else {
					return '<abbr class="required" title="Required" aria-label="Required">*</abbr>&#xA0;';
				}
			}

			public function required_mark_position_set($value) {
				if ($value == 'left' || $value == 'right' || $value == 'none') {
					$this->required_mark_position = $value;
				} else {
					exit_with_error('Invalid required mark position specified (left/right/none)');
				}
			}

			public function required_mark_position_get() {
				return $this->required_mark_position;
			}

			public function label_prefix_set($prefix) {
				$this->label_prefix_set_html(to_safe_html($prefix));
			}

			public function label_prefix_set_html($prefix_html) {
				$this->label_prefix_html = $prefix_html;
			}

			public function label_prefix_get_html() {
				return $this->label_prefix_html;
			}

			public function label_suffix_set($suffix) {
				$this->label_suffix_set_html(to_safe_html($suffix));
			}

			public function label_suffix_set_html($suffix_html) {
				$this->label_suffix_html = $suffix_html;
			}

			public function label_suffix_get_html() {
				return $this->label_suffix_html;
			}

			public function label_override_set_function($function) {
				$this->label_override_function = $function;
			}

			public function label_override_get_function() {
				return $this->label_override_function;
			}

			public function error_override_set_function($function) {
				$this->error_override_function = $function;
			}

			public function error_override_get_function() {
				return $this->error_override_function;
			}

			public function db_set($db_link) {
				$this->db_link = $db_link;
			}

			public function db_get() {
				if ($this->db_link === NULL) {
					$this->db_link = db_get();
				}
				return $this->db_link;
			}

			public function db_record_set($record) {
				$this->db_record = $record;
			}

			public function db_record_get() {
				if ($this->db_record === NULL) {
					if ($this->db_table_name_sql !== NULL) {

						$this->db_record = record_get([
								'table_sql'        => $this->db_table_name_sql,
								'table_alias'      => $this->db_table_alias_sql,
								'where_sql'        => $this->db_where_sql,
								'where_parameters' => $this->db_where_parameters,
								'log_table'        => $this->db_log_table,
								'log_values'       => $this->db_log_values,
							]);

					} else {

						$this->db_record = false;

					}
				}
				return $this->db_record;
			}

			public function db_table_set_sql($table_sql, $alias_sql = NULL) {
				$this->db_table_name_sql = $table_sql;
				$this->db_table_alias_sql = $alias_sql;
					// To deprecate:
					//   grep -r "db_table_set_sql(" */app/
					//   grep -r "db_where_set_sql(" */app/
					//   function db_field_set { grep "db_field_set" $1 | sed -E "s/.*\('([^']+)'.*/'\1',/"; }
			}

			public function db_where_set_sql($where_sql, $parameters = []) {
				$this->db_where_sql = $where_sql;
				$this->db_where_parameters = $parameters;
			}

			public function db_log_set($table, $values = []) {
				$this->db_log_table = $table;
				$this->db_log_values = $values;
			}

			public function db_save_disable() {
				$this->db_save_disabled = true;
			}

			public function db_value_set($name, $value) { // TODO: Look at using $record->value_set();
				$this->db_values[$name] = $value;
			}

			public function saved_message_set_html($message_html) {
				$this->saved_message_html = $message_html;
			}

			public function saved_values_ignore() { // The login form will skip them (should not be used, but also preserve them for the login redirect).
				$this->saved_values_ignore = true;
			}

			public function saved_values_available() {

				return false;

				if ($this->form_passive || $this->saved_values_ignore === true) {
					return false;
				}

				if ($this->saved_values_used === NULL) {

					$this->saved_values_used = false;

					if (session::open() && session::get('save_request_url') == config::get('request.uri') && config::get('request.method') == 'GET' && $this->form_method == 'POST') {

						$data = session::get('save_request_data');

						if (isset($data['act']) && $data['act'] == $this->form_id) {
							$this->saved_values_data = $data;
						}

					}

				}

				return ($this->saved_values_data !== NULL);

			}

			private function saved_values_used() {

				if ($this->saved_values_used === false && session::open()) {

					$this->saved_values_used = true;

					save_request_reset();

				}

			}

			public function saved_value_get($name) {

				$this->saved_values_used();

				if (isset($this->saved_values_data[$name])) {
					return $this->saved_values_data[$name];
				} else {
					return NULL;
				}

			}

		//--------------------------------------------------
		// Status

			public function submitted($page = NULL) {
				if ($this->form_submitted === true && $this->disabled === false && $this->readonly === false) {

					$this->saved_values_used(); // Just incase there are no fields on the page calling saved_value_get()

					if ($this->print_page_setup === NULL) {
						if ($page !== NULL) {
							exit_with_error('Cannot call form->submitted(' . $page . ') without form->print_page_start(X)');
						}
						return true;
					} else {
						if ($page === NULL) {
							$page = $this->print_page_setup;
						}
						return ($page <= $this->print_page_submit);
					}

				}
				return false;
			}

			private function _is_submitted() {

				$this->form_submitted = ($this->form_passive || (request('act', $this->form_method) == $this->form_id && config::get('request.method') == $this->form_method));

				if ($this->dedupe_user_id > 0 && $this->form_submitted && $this->form_method == 'POST') { // GET requests should not change state

					$this->dedupe_ref = request('r');
					$this->dedupe_data = hash('sha256', json_encode($_REQUEST));

					if ($this->dedupe_ref) {

						$db = $this->db_get();

						$sql = 'SELECT
									sfd.redirect_url,
									sfd.redirect_config
								FROM
									' . DB_PREFIX . 'system_form_dedupe AS sfd
								WHERE
									sfd.user_id = ? AND
									sfd.ref = ? AND
									sfd.data = ?
								LIMIT
									1';

						$parameters = [];
						$parameters[] = intval($this->dedupe_user_id);
						$parameters[] = $this->dedupe_ref;
						$parameters[] = $this->dedupe_data;

						if ($row = $db->fetch_row($sql, $parameters)) {
							config::set('debug.response_code_extra', 'deduped');
							redirect($row['redirect_url'], json_decode($row['redirect_config']));
							exit(); // Protect against the config containing ['exit' => false]
						}

					}

				}

			}

			public function initial($page = NULL) { // Because you cant have a function called "default", and "defaults" implies an array of default values.
				return (!$this->submitted($page) && !$this->saved_values_available());
			}

			public function valid() {

				$this->_post_validation();

				if (count($this->errors_html) > 0) {

					if (function_exists('response_get')) {
						$response = response_get();
						$response->error_set(true); // Changes the page title
					}

					return false;

				} else {

					$this->print_page_valid = true;

					return true;

				}

			}

		//--------------------------------------------------
		// CSRF

			public function csrf_error_set($error) {
				$this->csrf_error_set_html(to_safe_html($error));
			}

			public function csrf_error_set_html($error_html) {
				$this->csrf_error_html = $error_html;
			}

			public function csrf_token_get() {
				if ($this->form_method == 'POST') {
					return csrf_challenge_hash($this->form_action, $this->csrf_token);
				} else {
					return $this->csrf_token;
				}
			}

		//--------------------------------------------------
		// Fetch limits

			public function fetch_allowed_nested() { // e.g. in an iframe.
				$this->fetch_allowed_add('dest', 'nested-document');
				$this->fetch_allowed_add('mode', 'nested-navigate');
			}

			public function fetch_allowed_fetch() { // e.g. XMLHttpRequest, fetch(), navigator.sendBeacon(), <a download="">, <a ping="">, <link rel="prefetch">
				$this->fetch_allowed_add('dest', 'empty');
				$this->fetch_allowed_add('mode', 'cors');
				$this->fetch_allowed_add('mode', 'no-cors'); // navigator.sendBeacon
			}

			public function fetch_allowed_cors() {
				report_add('Deprecated: $form->fetch_allowed_cors(), either set manually, or use $form->fetch_allowed_fetch() which is more likely what you need.', 'notice');
				$this->fetch_allowed_add('mode', 'cors');
			}

			public function fetch_allowed_add($field, $value) {
				$this->fetch_allowed_set($field, array_merge($this->fetch_allowed[$field], [$value]));
			}

			public function fetch_allowed_set($field, $values) {
				$values = array_unique($values);
				if (!isset($this->fetch_known[$field])) {
					exit_with_error('Unknown fetch field "' . $field . '"');
				} else {
					$unknown = array_diff($values, $this->fetch_known[$field]);
					if (count($unknown) > 0) {
						exit_with_error('Unknown fetch value "' . reset($unknown) . '"');
					}
					$this->fetch_allowed[$field] = $values;
				}
			}

		//--------------------------------------------------
		// Error support

			public function error_reset() {
				$this->errors_html = [];
			}

			public function error_add($error, $hidden_info = NULL) {
				$this->error_add_html(to_safe_html($error), $hidden_info);
			}

			public function error_add_html($error_html, $hidden_info = NULL) {
				$this->_field_error_add_html(-1, $error_html, $hidden_info); // -1 is for general errors, not really linked to a field
			}

			public function errors_get() {
				$errors = [];
				foreach ($this->errors_get_html() as $error_html) {
					$errors[] = html_decode(strip_tags($error_html));
				}
				return $errors;
			}

			public function errors_get_html() {
				$this->_post_validation();
				$errors_flat_html = [];
				$error_links = config::get('form.error_links', false);
				ksort($this->errors_html); // Match order of fields
				foreach ($this->errors_html as $ref => $errors_html) {
					foreach ($errors_html as $error_html) {
						if ($error_links && isset($this->fields[$ref]) && stripos($error_html, '<a') === false) {
							$field_id = $this->fields[$ref]->input_first_id_get();
							if ($field_id) {
								$error_html = '<a href="#' . html($field_id) . '">' . $error_html . '</a>';
							}
						}
						$errors_flat_html[] = $error_html;
					}
				}
				return $errors_flat_html;
			}

			private function _post_validation() {

				//--------------------------------------------------
				// Already done

					if ($this->post_validation_done) {
						return true;
					}

				//--------------------------------------------------
				// CSRF checks

					$csrf_errors = [];
					$csrf_report = false;
					$csrf_block = (SERVER == 'stage'); // TODO: Remove, so all CSRF checks block (added 2019-05-27)

					if ($this->form_submitted && $this->csrf_error_html != NULL) { // Cant type check, as html() will convert NULL to string

						if ($this->print_page_skipped === true && config::get('request.method') == 'GET' && $this->form_method == 'POST') {

							// This multi-page form isn't complete yet, so don't show a CSRF error.

							// e.g. A GET request has been made, for a form that uses POST, where the request skips to a later 'page' in the process.

						} else {

							if ($this->form_passive) {
								$checks = config::get('form.csrf_passive_checks', []);
							} else {
								$checks = ['token', 'fetch'];
							}

							if (in_array('token', $checks)) {
								$csrf_token = strval(request('csrf', $this->form_method));
								if (!csrf_challenge_check($csrf_token, $this->form_action, $this->csrf_token)) {
									cookie::require_support();
									$csrf_errors[] = 'Token-[' . $this->csrf_token . ']-[' . $this->form_method . ']-[' . $csrf_token . ']';
									$csrf_block = true;
								}
							}

							if (in_array('cookie', $checks) && trim(strval(cookie::get('f'))) == '') { // The cookie just needs to exist, where it's marked SameSite=Strict
								$csrf_errors[] = 'MissingCookie = ' . implode('/', array_keys($_COOKIE));
								$csrf_report = true;
							}

							if (in_array('fetch', $checks) && $this->fetch_allowed !== false) {
								$fetch_values = config::get('request.fetch');
								if ($this->form_passive && $fetch_values['dest'] == 'document' && $fetch_values['mode'] == 'navigate' && $fetch_values['site'] == 'cross-site') {
									$csrf_report = false; // Top level 'navigate' to view a passive form, as a 'document', requested from another website (maybe email link)... but, because it's navigate/document, it's probably fine (a timing attack shouldn't be possible).
								} else if ($this->form_passive && $fetch_values['dest'] == 'empty' && $fetch_values['mode'] == 'navigate' && $fetch_values['site'] == 'same-origin') {
									$csrf_report = false; // Not sure why this happens, it might be a prefetch (e.g. https://chromestatus.com/feature/6276236312313856 but mode is not 'no-cors'), or a browser extention doing a fetch()... but, because it's same-origin, it's probably fine.
								} else {
									foreach ($this->fetch_allowed as $field => $allowed) {
										if ($fetch_values[$field] != NULL && !in_array($fetch_values[$field], $allowed)) {
											$csrf_errors[] = 'Sec-Fetch-' . ucfirst($field) . ' = "' . $fetch_values[$field] . '" (' . (in_array($fetch_values[$field], $this->fetch_known[$field]) ? 'known' : 'unknown') . ')';
											$csrf_report = true;
										}
									}
								}
							} else {
								$fetch_values = NULL;
							}

							if ($csrf_errors) {
								if ($csrf_report) {
									report_add('CSRF error via SecFetch/SameSite checks (' . ($csrf_block ? 'user asked to re-submit' : 'not blocked') . ').' . "\n\n" . 'Errors = ' . debug_dump($csrf_errors) . "\n\n" . 'Passive = ' . ($this->form_passive ? 'True' : 'False') . "\n\n" . 'Sec-Fetch Provided Values = ' . debug_dump($fetch_values) . "\n\n" . ' Sec-Fetch Allowed Values = ' . debug_dump($this->fetch_allowed), 'error');
								}
								if ($csrf_block) {
									$this->_field_error_add_html(-1, $this->csrf_error_html, implode('/', $csrf_errors));
								}
							}

						}

					}

				//--------------------------------------------------
				// Max input variables

					$input_vars_max = intval(ini_get('max_input_vars'));

					if ($input_vars_max > 0 && count($_REQUEST) >= $input_vars_max) {
						exit_with_error('The form submitted too many values for this server.', 'Maximum input variables: ' . $input_vars_max . ' (max_input_vars)');
					}

				//--------------------------------------------------
				// Max file uploads

					$file_uploads_max = intval(ini_get('max_file_uploads'));
					$file_upload_count = 0;
					foreach ($_FILES as $file) {
						$file_upload_count += (is_array($file['tmp_name']) ? count($file['tmp_name']) : 1);
					}
					if ($file_uploads_max > 0 && $file_upload_count >= $file_uploads_max && (PHP_INIT_ERROR['type'] ?? 0) === E_WARNING && strpos((PHP_INIT_ERROR['message'] ?? ''), 'Maximum number of allowable file uploads has been exceeded') !== NULL) {
						exit_with_error('The form submitted too many files for this server.', 'File uploads: ' . $file_upload_count . ', max_file_uploads = ' . $file_uploads_max . "\n\n" . debug_dump($_FILES));
					}

				//--------------------------------------------------
				// Saved value

					if (!$this->form_submitted && $this->saved_values_available() && $this->saved_message_html !== NULL) {
						$this->_field_error_add_html(-1, $this->saved_message_html);
					}

				//--------------------------------------------------
				// Fields

					foreach ($this->fields as $field) {
						$field->_post_validation();
					}

				//--------------------------------------------------
				// Remember this has been done

					$this->post_validation_done = true;

			}

		//--------------------------------------------------
		// Data output

			public function data_array_get() {

				$values = [];

				foreach ($this->fields as $field) {

					$field_name = $field->label_get_text();
					$field_type = $field->type_get();

					if ($field_type == 'date') {
						$value = $field->value_date_get();
						if ($value == '0000-00-00') {
							$value = ''; // Not provided
						}
					} else if ($field_type == 'file' || $field_type == 'image') {
						if ($field->uploaded()) {
							$value = $field->file_name_get() . ' (' . format_bytes($field->file_size_get()) . ')';
						} else {
							$value = 'N/A';
						}
					} else {
						$value = $field->value_get();
					}

					$values[$field->input_name_get()] = array($field_name, $value); // Input name should be unique

				}

				return $values;

			}

			public function data_db_get() {

				$values = [];

				foreach ($this->fields as $field) {
					$value_new = $field->_db_field_value_new_get();
					if ($value_new) {
						$values[$value_new[0]] = $value_new[1];
					}
				}

				foreach ($this->db_values as $name => $value) { // More reliable than array_merge at keeping keys
					$values[$name] = $value;
				}

				return $values;

			}

		//--------------------------------------------------
		// Database saving

			public function db_save() {

				//--------------------------------------------------
				// Validation

					if ($this->disabled) exit_with_error('This form is disabled, so you cannot call "db_save".');
					if ($this->readonly) exit_with_error('This form is readonly, so you cannot call "db_save".');

					if ($this->db_save_disabled) {
						exit_with_error('The "db_save" method has been disabled, you should probably be using an intermediate support object.');
					}

				//--------------------------------------------------
				// Record get

					$record = $this->db_record_get();

					if ($record === false) {
						exit_with_error('You need to call "db_record_set" or "db_table_set_sql" on the form object');
					}

					foreach ($this->fields as $field) {
						$field->_db_field_value_update();
					}

					if (is_array($record)) {

						if (count($this->db_values) > 0) {
							exit_with_error('Cannot use "db_value_set" with multiple record helpers, instead call "value_set" on the record itself.');
						}

						$records = $record;

					} else {

						foreach ($this->db_values as $name => $value) { // Values from db_value_set() replace those from $this->fields.
							$record->value_set($name, $value);
						}

						$records = array($record);

					}

					$changed = false;

					foreach ($records as $record) {
						if ($record->save() === true) {
							$changed = true;
						}
					}

					return $changed;

			}

			public function db_insert() {

				//--------------------------------------------------
				// Cannot use a WHERE clause

					if ($this->db_where_sql !== NULL) {
						exit_with_error('The "db_insert" method does not work with a "db_where_sql" set.');
					}

				//--------------------------------------------------
				// Save and return the ID

					$this->db_save();

					$db = $this->db_get();

					return $db->insert_id();

			}

		//--------------------------------------------------
		// Field support

			public function field_get($ref, $config = []) {

				if (is_numeric($ref)) {

					if (isset($this->fields[$ref])) {
						return $this->fields[$ref];
					} else {
						exit_with_error('Cannot return the field "' . $ref . '", on "' . get_class($this) . '".');
					}

				} else {

					if (isset($this->field_refs[$ref])) {

						return $this->field_refs[$ref];

					} else {

						$method = 'field_' . $ref . '_get';

						if (method_exists($this, $method)) {
							$field = $this->$method($config);
						} else {
							$field = $this->_field_create($ref, $config);
						}

						$this->field_refs[$ref] = $field;

						return $field;

					}

				}

			}

			protected function _field_create($ref, $config) {
				exit_with_error('Cannot create the "' . $ref . '" field, missing the "field_' . $ref . '_get" method on "' . get_class($this) . '".');
			}

			public function field_exists($ref) {
				return (isset($this->fields[$ref]) || isset($this->field_refs[$ref]));
			}

			public function fields_get($group = NULL) {
				if ($group === NULL) {
					return $this->fields;
				} else {
					$fields = [];
					foreach ($this->fields as $field) {
						if ($field->print_include_get() && !$field->print_hidden_get() && $field->print_group_get() == $group) {
							$fields[] = $field;
						}
					}
					return $fields;
				}
			}

			public function field_groups_get() {
				$field_groups = [];
				foreach ($this->fields as $field) {
					if ($field->print_include_get() && !$field->print_hidden_get()) {
						$field_group = $field->print_group_get();
						if ($field_group !== NULL) {
							$field_groups[] = $field_group;
						}
					}
				}
				return array_unique($field_groups);
			}

			public function _field_add($field_obj) { // Public for form_field to call
				while (isset($this->fields[$this->field_count])) {
					$this->field_count++;
				}
				$this->fields[$this->field_count] = $field_obj;
				$this->print_page_valid = false;
				return $this->field_count;
			}

			public function _field_setup_file() { // Public for form_field to call, will only action once (for this form)

				if ($this->file_setup_complete !== true) {

					$this->file_setup_complete = true;

					$this->form_attribute_set('enctype', 'multipart/form-data');

					if (session::open()) {
						session::regenerate_delay(60*20); // 20 minutes for the user to select the file(s), submit the form, and for the upload to complete (try to avoid issue with them using a second tab, and getting a new session key, while uploading).
					}

				}

			}

			public function _field_tag_id_get() { // Public for form_field to call
				return $this->form_id . '_tag_' . ++$this->field_tag_id;
			}

			public function _field_error_add_html($field_uid, $error_html, $hidden_info = NULL) {

				if ($this->error_override_function !== NULL) {
					$function = $this->error_override_function;
					if ($field_uid == -1) {
						$error_html = call_user_func($function, $error_html, $this, NULL);
					} else {
						$error_html = call_user_func($function, $error_html, $this, $this->fields[$field_uid]);
					}
				}

				if (!isset($this->errors_html[$field_uid])) {
					$this->errors_html[$field_uid] = [];
				}

				if ($hidden_info !== NULL) {
					$error_html .= ' <!-- ' . html($hidden_info) . ' -->';
				}

				$this->errors_html[$field_uid][] = $error_html;

				$this->print_page_valid = false;

			}

			public function _field_error_set_html($field_uid, $error_html, $hidden_info = NULL) {
				$this->errors_html[$field_uid] = [];
				$this->_field_error_add_html($field_uid, $error_html, $hidden_info);
			}

			public function _field_errors_get_html($field_uid) {
				if (isset($this->errors_html[$field_uid])) {
					return $this->errors_html[$field_uid];
				} else {
					return [];
				}
			}

			public function _field_valid($field_uid) {
				return (!isset($this->errors_html[$field_uid]));
			}

		//--------------------------------------------------
		// HTML

			public function html_start($config = NULL) {

				//--------------------------------------------------
				// Config

					if (!is_array($config)) {
						$config = [];
					}

				//--------------------------------------------------
				// Remove query string if in GET mode

					$form_action = $this->form_action;

					if ($this->form_method == 'GET') {

						$pos = strpos($form_action, '?');
						if ($pos !== false) {

							$form_action = substr($form_action, 0, $pos);

							$pos = strrpos($this->form_action, '#');
							if ($pos !== false) {
								$form_action .= substr($this->form_action, $pos);
							}

						}

					}

				//--------------------------------------------------
				// Hidden fields

					if (!isset($config['hidden']) || $config['hidden'] !== true) {
						$hidden_fields_html = $this->html_hidden();
					} else {
						$hidden_fields_html = '';
					}

					unset($config['hidden']);

				//--------------------------------------------------
				// Attributes

					$attributes = array(
						'id' => $this->form_id,
						'class' => ($this->form_class == '' ? NULL : $this->form_class),
						'action' => $form_action,
						'method' => strtolower($this->form_method), // Lowercase for the HTML5 checker on totalvalidator.com
						'accept-charset' => config::get('output.charset'), // When text from MS Word is pasted in an IE6 input field, it does not translate to UTF-8
					);

					if ($this->autocomplete !== NULL) {
						$attributes['autocomplete'] = ($this->autocomplete && $this->autocomplete !== 'off' ? 'on' : 'off'); // Can only be on/off, unlike a field
					}

					$attributes = array_merge($attributes, $this->form_attributes);
					$attributes = array_merge($attributes, $config);

				//--------------------------------------------------
				// Return HTML

					return html_tag('form', $attributes) . $hidden_fields_html;

			}

			public function html_hidden($config = NULL) {

				//--------------------------------------------------
				// Config

					if (!is_array($config)) {
						$config = [];
					}

					if (!isset($config['wrapper'])) $config['wrapper'] = 'div';
					if (!isset($config['class'])) $config['class'] = 'form_hidden_fields';

					$field_names = [];

					foreach ($this->fields as $field) {
						if ($field->type_get() != 'info') {

							$field_name = $field->input_name_get();
							$field_names[] = $field_name;

							$field_value = $field->value_hidden_get(); // File field may always return a value, irrespective of $field->print_hidden
							if ($field_value !== NULL) {
								$this->hidden_values['h-' . $field_name] = $field_value;
							}

						}
					}

					if ($this->form_button_name !== NULL) {
						$field_names[] = $this->form_button_name;
					}

				//--------------------------------------------------
				// Input fields - use array to keep unique keys

					$input_fields = [];

					if (!$this->form_passive) {

						$input_fields['act'] = ['value' => $this->form_id];

						$original_request = intval(request('o'));
						if ($original_request == 0) {
							$original_request = time();
						}
						$input_fields['o'] = ['value' => $original_request];

						if ($this->dedupe_user_id > 0) {
							$input_fields['r'] = ['value' => random_key(15)]; // Request identifier
						}

						if ($this->csrf_error_html != NULL) {
							$input_fields['csrf'] = ['value' => $this->csrf_token_get()];
						}

					}

					foreach ($this->hidden_values as $name => $field) {
						if (isset($input_fields[$name])) {
							exit_with_error('The hidden field "' . $name . '" already exists.');
						}
						if (!is_array($field)) {
							$field = ['value' => $field];
						}
						if (!isset($field['urlencode']) || $field['urlencode'] !== false) {
							$field['value'] = rawurlencode($field['value']); // URL encode allows newline characters to exist in hidden (one line) input fields.
						}
						$input_fields[$name] = $field;
					}

					if ($this->form_method == 'GET') {
						$form_action_query = @parse_url($this->form_action, PHP_URL_QUERY);
						if ($form_action_query) {
							parse_str($form_action_query, $form_action_query);
							foreach ($form_action_query as $name => $value) {
								if (!isset($input_fields[$name]) && !in_array($name, $field_names) && !is_array($value)) { // Currently does not support ./?a[]=1&a[]=2&a[]=3
									$input_fields[$name] = ['value' => $value];
								}
							}
						}
					}

				//--------------------------------------------------
				// HTML

					$html = '';

					if ($config['wrapper'] !== NULL) {
						$html .= '<' . html($config['wrapper']) . ' class="' . html($config['class']) . '">';
					}

					foreach ($input_fields as $name => $field) {
						$attributes = ['type' => 'hidden', 'name' => $name, 'value' => $field['value']];
						if (isset($field['attributes'])) {
							$attributes = array_merge($attributes, $field['attributes']);
						}
						$html .= html_tag('input', $attributes);
					}

					if ($config['wrapper'] !== NULL) {
						$html .= '</' . html($config['wrapper']) . '>' . "\n";
					}

					return $html;

			}

			public function html_error_list($config = NULL) {

				$errors_flat_html = $this->errors_get_html();

				$html = '';
				if (count($errors_flat_html) > 0) {
					$html  = config::get('form.error_prefix_html', '');
					$html .= '<ul role="alert"' . (isset($config['id']) ? ' id="' . html($config['id']) . '"' : '') . ' class="' . html(isset($config['class']) ? $config['class'] : 'error_list') . '">';
					foreach ($errors_flat_html as $err) $html .= '<li>' . $err . '</li>';
					$html .= '</ul>';
					$html .= config::get('form.error_suffix_html', '');
				}

				return $html;

			}

			public function html_fields($group = NULL) {

				//--------------------------------------------------
				// Start

					$k = 0;
					$html = '';

				//--------------------------------------------------
				// Auto focus

					if ($this->autofocus) {
						foreach ($this->fields as $field_uid => $field) {
							if ($field->autofocus_auto_set()) {
								$this->autofocus_submit = false;
								break;
							}
						}
					}

				//--------------------------------------------------
				// Field groups

					$field_groups = [];

					if ($group !== NULL) {

						$field_groups = array($group);

					} else {

						foreach ($this->fields as $field) {
							if ($field->print_include_get() && !$field->print_hidden_get()) {
								$field_group = $field->print_group_get();
								if ($field_group === NULL) {
									$field_groups = array(NULL);
									break;
								} else if ($field_group !== false) { // When using $form->print_group_start(false);
									$field_groups[] = $field_group;
								}
							}
						}

					}

					$field_groups = array_values(array_unique($field_groups)); // And re-index

					$group_headings = (count($field_groups) > 1);

				//--------------------------------------------------
				// Fields HTML

					$html = '';

					foreach ($field_groups as $k => $group) {

						if ($this->print_group_tag == 'fieldset') {

							if ($k > 0) {
								$html .= "\n\t\t\t\t" . '</fieldset>' . "\n";
							}

							$html .= "\n\t\t\t\t" . '<fieldset' . ($this->print_group_class ? ' class="' . html($this->print_group_class) . '"' : '') . '>' . "\n";
							if ($group !== '') {
								$html .= "\n\t\t\t\t\t" . '<legend>' . html($group) . '</legend>' . "\n";
							}

						} else if ($group_headings) {

							if ($group !== '') {
								$html .= "\n\t\t\t\t" . '<' . html($this->print_group_tag) . '' . ($this->print_group_class ? ' class="' . html($this->print_group_class) . '"' : '') . '>' . html($group) . '</' . html($this->print_group_tag) . '>' . "\n";
							}

						}

						foreach ($this->fields as $field) {

							if ($field->print_include_get() && !$field->print_hidden_get()) {

								$field_group = $field->print_group_get();

								if (($group === NULL && $field_group === NULL) || ($group !== NULL && $group == $field_group)) {

									$k++;

									if ($k == 1) {
										$field->wrapper_class_add('first odd');
									} else if ($k % 2) {
										$field->wrapper_class_add('odd');
									} else {
										$field->wrapper_class_add('even');
									}

									$html .= $field->html();

								}

							}

						}

					}

					if ($this->print_group_tag == 'fieldset' && $html != '') {
						$html .= "\n\t\t\t\t" . '</fieldset>' . "\n";
					}

				//--------------------------------------------------
				// Return

					return $html;

			}

			public function html_submit($buttons = NULL) {
				if ($this->disabled === false && $this->readonly === false) {

					if ($buttons === NULL) {
						$buttons = $this->form_button;
					}

					if ($buttons === NULL) {
						return;
					}

					if (!is_array($buttons) || is_assoc($buttons)) {
						$buttons = array($buttons);
					}

					$html = '
							<div class="row submit">';

					$k = 0;

					foreach ($buttons as $attributes) {

						$k++;

						if (!is_array($attributes)) {
							if ($attributes instanceof html_template || $attributes instanceof html_safe_value) {
								$attributes = ['html' => $attributes];
							} else {
								$attributes = ['value' => $attributes];
							}
						}

						if (isset($attributes['text'])) {
							$attributes['html'] = html($attributes['text']);
							if (isset($attributes['url'])) {
								$attributes['html'] = '<a href="' . html($attributes['url']) . '"' . (isset($attributes['class']) ? ' class="' . html($attributes['class']) . '"' : '') . '>' . $attributes['html'] . '</a>';
							}
						}

						if (isset($attributes['html'])) {
							$html .= '
								' . $attributes['html'];
						} else {
							if (!isset($attributes['value'])) {
								$attributes['value'] = 'Save';
							}
							if ($this->autofocus_submit && $k == 1) {
								$attributes['autofocus'] = 'autofocus';
							}
							$html .= '
								' . html_tag('input', array_merge(array('type' => 'submit', 'name' => $this->form_button_name), $attributes));
						}

					}

					return $html . '
							</div>';

				} else {

					return '';

				}
			}

			public function html_end() {
				return '</form>' . "\n";
			}

			public function html() {
				return '
					' . rtrim($this->html_start()) . '
						<' . html($this->wrapper_tag) . '>
							' . $this->html_error_list() . '
							' . $this->html_fields() . '
							' . $this->html_submit() . '
						</' . html($this->wrapper_tag) . '>
					' . $this->html_end() . "\n";
			}

		//--------------------------------------------------
		// Shorter representation in debug_dump()

			public function _debug_dump() {
				return 'form()';
			}

	}

	class form_field {

		//--------------------------------------------------
		// Variables

			protected $form = NULL;
			protected $form_field_uid = NULL;
			protected $form_submitted = false;

			protected $id = NULL;
			protected $name = NULL;
			protected $type = 'unknown';
			protected $wrapper_tag = 'div';
			protected $wrapper_id = NULL;
			protected $wrapper_class = '';
			protected $wrapper_data = [];
			protected $wrapper_attributes = [];
			protected $label_html = '';
			protected $label_aria = NULL;
			protected $label_prefix_html = '';
			protected $label_suffix_html = '';
			protected $label_class = NULL;
			protected $label_wrapper_tag = 'span';
			protected $label_wrapper_class = 'label';
			protected $input_first = false;
			protected $input_class = NULL;
			protected $input_data = [];
			protected $input_attributes = [];
			protected $input_wrapper_tag = 'span';
			protected $input_wrapper_class = 'input';
			protected $input_described_by = [];
			protected $format_class = 'format';
			protected $format_tag = 'span';
			protected $info_html = NULL;
			protected $info_class = 'info';
			protected $info_tag = 'span';
			protected $required = false;
			protected $required_mark_html = NULL;
			protected $required_mark_position = NULL;
			protected $autofocus = NULL;
			protected $autocorrect = NULL;
			protected $autocomplete = NULL;
			protected $autocapitalize = NULL;
			protected $disabled = false;
			protected $readonly = false;
			protected $print_group = NULL;
			protected $print_include = true;
			protected $print_hidden = false;
			protected $db_record = NULL;
			protected $db_field_name = NULL;
			protected $db_field_key = false;
			protected $db_field_info = NULL;

		//--------------------------------------------------
		// Setup

			public function __construct($form, $label, $name = NULL) {
				$this->setup($form, $label, $name, 'unknown');
			}

			protected function setup($form, $label, $name, $type) {

				//--------------------------------------------------
				// Add this field to the form, and return a "UID"
				// NOTE: The "ID" is a string for the HTML

					$form_field_uid = $form->_field_add($this);

				//--------------------------------------------------
				// Label

					$function = $form->label_override_get_function();
					if ($function !== NULL) {
						$label = call_user_func($function, $label, $form, $this);
					}

					$label_html = to_safe_html($label);

				//--------------------------------------------------
				// Name

					if ($name == '') { // Auto generate a name

						$name = substr(human_to_ref($label), 0, 30); // Trim really long labels

						if ($name == '' && !in_array($type, ['info', 'html'])) {
							exit_with_error('Cannot have a field with no name.', $type);
						}

						$k = 1;
						$name_original = $name;
						while (config::array_search('form.fields', $name) !== false) { // Ensure it's unique - provided names don't use this check, e.g. "names[]"
							$name = $name_original . '_' . ++$k;
						}

					}

					$this->input_name_set($name);

				//--------------------------------------------------
				// Field configuration

					$this->form = $form;
					$this->form_field_uid = $form_field_uid;
					$this->form_submitted = $form->submitted();

					$this->id = 'fld_' . human_to_ref($this->name);

					$this->type = $type;

					$this->label_html = $label_html;
					$this->label_prefix_html = $form->label_prefix_get_html();
					$this->label_suffix_html = $form->label_suffix_get_html();

					$this->autocomplete = $form->autocomplete_default_get();
					$this->disabled = $form->disabled_get();
					$this->readonly = $form->readonly_get();
					$this->print_group = $form->print_group_get();

			}

			public function form_get() {
				return $this->form;
			}

			public function uid_get() {
				return $this->form_field_uid;
			}

			public function type_get() {
				return $this->type;
			}

			public function wrapper_tag_set($tag) {
				$this->wrapper_tag = $tag;
			}

			public function wrapper_id_set($id) {
				$this->wrapper_id = $id;
			}

			public function wrapper_class_set($class) {
				$this->wrapper_class = $class;
			}

			public function wrapper_class_add($class) {
				$this->wrapper_class .= ($this->wrapper_class == '' ? '' : ' ') . $class;
			}

			public function wrapper_class_get() {

				$class = array('row', $this->type, $this->wrapper_class, $this->name);

				if (!$this->valid()) {
					$class[] = 'error';
				}

				return implode(' ', array_filter($class));

			}

			public function wrapper_data_set($field, $value) {
				$this->wrapper_data[$field] = $value;
			}

			public function wrapper_attribute_set($attribute, $value) { // Try to use wrapper_data_set(), this is for things like `dir="rtl"`
				if ($value === NULL) {
					unset($this->wrapper_attributes[$attribute]);
				} else {
					$this->wrapper_attributes[$attribute] = $value;
				}
			}

			public function label_set($label) {
				$this->label_html = to_safe_html($label);
			}

			public function label_set_html($label_html) {
				$this->label_html = $label_html;
			}

			public function label_get_html() {
				return $this->label_html;
			}

			public function label_get_text() { // Text suffix used as it's processed data
				return html_decode(strip_tags($this->label_html));
			}

			public function label_aria_set($label) {
				$this->label_aria = $label;
			}

			public function label_prefix_set($prefix) {
				$this->label_prefix_set_html(to_safe_html($prefix));
			}

			public function label_prefix_set_html($prefix_html) {
				$this->label_prefix_html = $prefix_html;
			}

			public function label_suffix_set($suffix) {
				$this->label_suffix_set_html(to_safe_html($suffix));
			}

			public function label_suffix_set_html($suffix_html) {
				$this->label_suffix_html = $suffix_html;
			}

			public function label_class_set($class) {
				$this->label_class = $class;
			}

			public function label_wrapper_tag_set($tag) {
				$this->label_wrapper_tag = $tag;
			}

			public function label_wrapper_class_set($class) {
				$this->label_wrapper_class = $class;
			}

			public function input_id_set($id) {
				$this->id = $id;
			}

			public function input_id_get() {
				return $this->id;
			}

			public function input_first_id_get() {
				return $this->id;
			}

			public function input_name_set($name) { // name usually set on init, use this function ONLY if you really need to change it afterwards.

				if ($this->name !== NULL) { // Remove the old name from list of used names
					$fields = config::get('form.fields');
					if (is_array($fields)) {
						$key = array_search($this->name, $fields);
						if ($key !== false) {
							unset($fields[$key]);
							config::set('form.fields', $fields);
						}
					}
				}

				$this->name = $name;

				config::array_push('form.fields', $this->name);

			}

			public function input_name_get() {
				return $this->name;
			}

			public function input_class_set($class) {
				$this->input_class = $class;
			}

			public function input_data_set($field, $value) {
				$this->input_data[$field] = $value;
			}

			public function input_attribute_set($attribute, $value) { // Try to use input_data_set(), this is for things like `dir="rtl"`
				if ($value === NULL) {
					unset($this->input_attributes[$attribute]);
				} else {
					$this->input_attributes[$attribute] = $value;
				}
			}

			public function input_first_set($first = NULL) {

				$this->input_first = ($first == true);
				$this->label_prefix_html = ($first ? '' : $this->form->label_prefix_get_html());
				$this->label_suffix_html = ($first ? '' : $this->form->label_suffix_get_html());

				if ($this->required_mark_position === NULL) { // Ignore if already set
					$this->required_mark_position_set($first ? 'right' : 'left');
				}

			}

			public function input_first_get() {
				return $this->input_first;
			}

			public function input_wrapper_tag_set($tag) {
				$this->input_wrapper_tag = $tag;
			}

			public function input_wrapper_class_set($class) {
				$this->input_wrapper_class = $class;
			}

			public function format_default_get_html() {
				return '';
			}

			public function format_class_set($class) {
				$this->format_class = $class;
			}

			public function format_tag_set($tag) {
				$this->format_tag = $tag;
			}

			public function info_set($info) {
				$this->info_set_html($info === NULL ? NULL : to_safe_html($info)); // An empty string can be used for the element to exist (e.g. for JS to populate)
			}

			public function info_set_html($info_html) {
				$this->info_html = $info_html;
			}

			public function info_get_html() {
				return $this->info_html;
			}

			public function info_class_set($class) {
				$this->info_class = $class;
			}

			public function info_tag_set($tag) {
				$this->info_tag = $tag;
			}

			public function required_mark_set($required_mark) {
				$this->required_mark_set_html($required_mark === true ? true : to_safe_html($required_mark));
			}

			public function required_mark_set_html($required_mark_html) {
				$this->required_mark_html = $required_mark_html;
			}

			public function required_mark_get_html($required_mark_position = NULL) {
				if ($this->required || $this->required_mark_html !== NULL) {
					if ($this->required_mark_html !== NULL && $this->required_mark_html !== true) {
						return $this->required_mark_html;
					} else {
						return $this->form->required_mark_get_html($required_mark_position);
					}
				} else {
					return '';
				}
			}

			public function required_mark_position_set($position) {
				if ($position == 'left' || $position == 'right' || $position == 'none') {
					$this->required_mark_position = $position;
				} else {
					exit_with_error('Invalid required mark position specified (left/right/none)');
				}
			}

			public function autofocus_set($autofocus) {
				$this->autofocus = ($autofocus == true);
			}

			public function autofocus_auto_set() {
				if ($this->autofocus === NULL) { // Has been set manually
					if (!$this->valid()) {
						$this->autofocus = true;
					} else if (method_exists($this, '_value_print_get')) {
						$value = $this->_value_print_get();
						if (is_array($value)) {
							$this->autofocus = (count(array_filter($value)) == 0); // Where $value may be [0,0,0] on a date field (when the form is submitted).
						} else {
							$this->autofocus = (strval($value) == '');
						}
					}
				}
				return $this->autofocus;
			}

			public function autofocus_get() {
				return $this->autofocus;
			}

			public function autocorrect_set($autocorrect) {
				$this->autocorrect = ($autocorrect == true);
			}

			public function autocorrect_get() {
				return $this->autocorrect;
			}

			public function autocomplete_set($autocomplete) {
				$this->autocomplete = $autocomplete;
			}

			public function autocomplete_get() {
				return $this->autocomplete;
			}

			public function autocapitalize_set($autocapitalize) {
				$this->autocapitalize = $autocapitalize;
			}

			public function autocapitalize_get() {
				return $this->autocapitalize;
			}

			public function disabled_set($disabled) {
				$this->disabled = ($disabled == true);
			}

			public function disabled_get() {
				return $this->disabled;
			}

			public function readonly_set($readonly) {
				$this->readonly = ($readonly == true);
			}

			public function readonly_get() {
				return $this->readonly;
			}

			public function print_include_set($include) { // Print on main form automatically
				$this->print_include = ($include == true);
			}

			public function print_include_get() {
				return $this->print_include;
			}

			public function print_hidden_set($hidden) { // Won't print on main form automatically, but will preserve value in a hidden field
				$this->print_hidden = ($hidden == true);
			}

			public function print_hidden_get() {
				return $this->print_hidden;
			}

			public function print_group_set($group) {
				$this->print_group = $group;
			}

			public function print_group_get() {
				return $this->print_group;
			}

			protected function _db_field_set($a, $b = NULL, $c = NULL) {

				$form_record = $this->form->db_record_get();

				if ($a instanceof record) {

					$record = $a;
					$field_name = $b;
					$field_type = $c;

					if (!in_array($record, (is_array($form_record) ? $form_record : array($form_record)))) {
						exit_with_error('The form helper needs to be told about the record for "' . $field_name . '" by using $form->db_record_set(array($record1, $record2, ...))');
					}

				} else {

					$record = $form_record;
					if (!($record instanceof record)) {
						exit_with_error('Please specify a record to use when setting the db field for "' . $this->name . '"');
					}

					$field_name = $a;
					$field_type = $b;

				}

				if ($this->db_field_name !== NULL && $this->db_field_name != $field_name) {
					if (SERVER == 'stage') {
						exit_with_error('Changing the "' . $this->label_get_text() . '" db_field from "' . $this->db_field_name . '" to "' . $field_name . '"');
					} else {
						report_add('Changing the "' . $this->label_get_text() . '" db_field from "' . $this->db_field_name . '" to "' . $field_name . '"', 'error'); // TODO: Change to an exit_with_error
					}
				}

				$this->db_record = $record;
				$this->db_field_name = $field_name;
				$this->db_field_key = ($field_type == 'key');

				$this->db_field_info = $record->field_get($field_name); // Will exit_with_error if invalid.

				$record->field_name_add($field_name); // Temp (only until all projects use the record helper)

			}

			public function db_field_set($a, $b = NULL) {
				$this->_db_field_set($a, $b);
			}

			public function db_field_name_get() {
				return $this->db_field_name;
			}

			public function db_field_key_get() {
				return $this->db_field_key;
			}

			public function db_field_info_get($key = NULL) {
				if ($key) {
					if (isset($this->db_field_info[$key])) {
						return $this->db_field_info[$key];
					}
				} else {
					if (isset($this->db_field_info)) {
						return $this->db_field_info;
					}
				}
				return NULL;
			}

			public function db_field_value_get() {
				return $this->db_record->value_get($this->db_field_name);
			}

			public function _db_field_value_new_get() {

				if ($this->db_field_name !== NULL && !$this->disabled && !$this->readonly) {

					if ($this->db_field_key) {
						$field_value = $this->value_key_get();
					} else if ($this->type == 'date') {
						$field_value = $this->value_date_get();
					} else {
						$field_value = $this->value_get();
					}

					if ($this->db_field_info['null']) {
						if ($field_value === '' && in_array($this->db_field_info['type'], ['int', 'decimal'])) {
							$field_value = NULL; // e.g. number field setting an empty string (not 0).
						}
					} else {
						if ($field_value === NULL) {
							$field_value = ''; // e.g. enum with "not null" and select field with selected label.
						}
					}

					return array($this->db_field_name, $field_value);

				} else {

					return NULL; // Not setting the field to NULL

				}

			}

			public function _db_field_value_update() {
				$value_new = $this->_db_field_value_new_get();
				if ($value_new) {
					$this->db_record->value_set($value_new[0], $value_new[1]);
				}
			}

		//--------------------------------------------------
		// Errors

			public function error_set($error) {
				$this->error_set_html(to_safe_html($error));
			}

			public function error_set_html($error_html) {
				$this->form->_field_error_set_html($this->form_field_uid, $error_html);
			}

			public function error_add($error, $hidden_info = NULL) {
				$this->error_add_html(to_safe_html($error), $hidden_info);
			}

			public function error_add_html($error_html, $hidden_info = NULL) {
				$this->form->_field_error_add_html($this->form_field_uid, $error_html, $hidden_info);
			}

			public function error_count() {
				return count($this->errors_get_html());
			}

			public function errors_get_html() {
				return $this->form->_field_errors_get_html($this->form_field_uid);
			}

		//--------------------------------------------------
		// Value

			public function value_hidden_get() {
				if ($this->print_hidden) {
					return '';
				} else {
					return NULL;
				}
			}

		//--------------------------------------------------
		// Status

			public function valid() {
				return $this->form->_field_valid($this->form_field_uid);
			}

		//--------------------------------------------------
		// Validation

			public function _post_validation() {
			}

		//--------------------------------------------------
		// Attributes

			protected function _input_attributes() {

				$attributes = array_merge($this->input_attributes, [
						'name' => $this->name,
						'id' => $this->id,
					]);

				foreach ($this->input_data as $field => $value) {
					$attributes['data-' . $field] = $value;
				}

				if ($this->input_class !== NULL) {
					$attributes['class'] = $this->input_class;
				}

				if ($this->required) {
					$attributes['required'] = 'required';
				}

				if ($this->autofocus) {
					$attributes['autofocus'] = 'autofocus';
				}

				if ($this->autocorrect !== NULL) {
					$attributes['autocorrect'] = ($this->autocorrect ? 'on' : 'off');
				}

				if ($this->autocomplete !== NULL) {
					$attributes['autocomplete'] = (is_string($this->autocomplete) ? $this->autocomplete : ($this->autocomplete ? 'on' : 'off'));
				}

				if ($this->autocapitalize !== NULL) {
					$attributes['autocapitalize'] = (is_string($this->autocapitalize) ? $this->autocapitalize : ($this->autocapitalize ? 'sentences' : 'none'));
				}

				if ($this->disabled) {
					$attributes['disabled'] = 'disabled';
				}

				if ($this->readonly) {
					$attributes['readonly'] = 'readonly';
				}

				if ($this->label_aria) {
					$attributes['aria-label'] = $this->label_aria;
				}

				if (!$this->valid()) {
					$attributes['aria-invalid'] = 'true';
				}

				if ($this->input_described_by !== NULL && count($this->input_described_by) > 0) {
					$attributes['aria-describedby'] = implode(' ', $this->input_described_by);
				}

				return $attributes;

			}

		//--------------------------------------------------
		// HTML

			public function html_label($label_html = NULL) {

				//--------------------------------------------------
				// Required mark

					$required_mark_position = $this->required_mark_position;
					if ($required_mark_position === NULL) {
						$required_mark_position = $this->form->required_mark_position_get();
					}

					$required_mark_html = $this->required_mark_get_html($required_mark_position);

				//--------------------------------------------------
				// Return the HTML for the label

					if ($label_html === NULL) {
						$label_html = $this->label_html;
					}

					if ($label_html != '') {
						$label_html = $this->label_prefix_html . '<label for="' . html($this->id) . '"' . ($this->label_class === NULL ? '' : ' class="' . html($this->label_class) . '"') . '>' . ($required_mark_position == 'left' && $required_mark_html !== NULL ? $required_mark_html : '') . $label_html . ($required_mark_position == 'right' && $required_mark_html !== NULL ? $required_mark_html : '') . '</label>' . $this->label_suffix_html;
					}

					return new html_safe_value($label_html);

			}

			protected function _html_input($attributes_custom = []) {
				return html_tag('input', array_merge($this->_input_attributes(), $attributes_custom));
			}

			public function html_input() {
				return 'ERROR';
			}

			public function html_format($indent = 0) {
				$format_html = $this->format_default_get_html();
				if ($format_html == '') {
					return '';
				} else {
					return ($indent > 0 ? "\n" : '') . str_repeat("\t", $indent) . '<' . html($this->format_tag) . ' class="' . html($this->format_class) . '">' . $format_html . '</' . html($this->format_tag) . '>';
				}
			}

			public function html_info($indent = 0) {
				if ($this->info_html === NULL) {
					return '';
				} else {
					$tag_id = $this->form->_field_tag_id_get();
					if ($this->input_described_by !== NULL) {
						$this->input_described_by[] = $tag_id;
					}
					return ($indent > 0 ? "\n" : '') . str_repeat("\t", $indent) . '<' . html($this->info_tag) . ' class="' . html($this->info_class) . '" id="' . html($tag_id) . '">' . $this->info_html . '</' . html($this->info_tag) . '>';
				}
			}

			public function html() {

				$info_html = $this->html_info(8); // Adds to input_described_by, so the input field can include "aria-describedby"
				$format_html = $this->html_format(8);

				$label_html = $this->html_label();
				if ($label_html != '') { // Info fields might not specify a label
					$label_html = '<' . html($this->label_wrapper_tag) . ' class="' . html($this->label_wrapper_class) . '">' . $label_html . '</' . html($this->label_wrapper_tag) . '>';
				}

				if (method_exists($this, 'html_input_by_key')) {
					$html = '
								' . $label_html . $this->html_input() . $format_html . $info_html;
				} else {
					$input_html = '<' . html($this->input_wrapper_tag) . ' class="' . html($this->input_wrapper_class) . '">' . $this->html_input() . '</' . html($this->input_wrapper_tag) . '>';
					if ($this->input_first) {
						$html = '
								' . $input_html . '
								' . $label_html . $format_html . $info_html;
					} else {
						$html = '
								' . $label_html . '
								' . $input_html . $format_html . $info_html;
					}
				}

				$wrapper_attributes = array_merge($this->wrapper_attributes, [
						'id' => $this->wrapper_id,
						'class' => $this->wrapper_class_get() . ($this->input_first ? ' input_first' : ''),
					]);

				foreach ($this->wrapper_data as $field => $value) {
					$wrapper_attributes['data-' . $field] = $value;
				}

				return '
							' . html_tag($this->wrapper_tag, $wrapper_attributes) . $html . '
							</' . html($this->wrapper_tag) . '>' . "\n";

			}

		//--------------------------------------------------
		// Shorter representation in debug_dump()

			public function _debug_dump() {
				if (isset($this->value)) {
					$value = '"' . $this->value . '"';
				} else if (isset($this->values)) {
					$value = debug_dump($this->values, 2);
				} else if (method_exists($this, 'file_name_get')) {
					$value = $this->file_name_get();
				} else {
					$value = 'NULL';
				}
				return get_class($this) . ' = ' . $value;
			}

	}

	class form_field_text extends form_field {

		//--------------------------------------------------
		// Variables

			protected $value;

			protected $min_length = NULL;
			protected $max_length = NULL;
			protected $placeholder = NULL;
			protected $input_type = 'text';
			protected $input_mode = NULL;
			protected $input_size = NULL;
			protected $input_list_id = NULL;
			protected $input_list_options = NULL;

		//--------------------------------------------------
		// Setup

			public function __construct($form, $label, $name = NULL) {
				$this->setup_text($form, $label, $name, 'text');
			}

			protected function setup_text($form, $label, $name, $type) {

				//--------------------------------------------------
				// Perform the standard field setup

					$this->setup($form, $label, $name, $type);

				//--------------------------------------------------
				// Value

					$this->value = NULL;

					if ($this->form_submitted || $this->form->saved_values_available()) {

						if ($this->form_submitted) {
							$this->value = request($this->name, $this->form->form_method_get());
						} else {
							$this->value = $this->form->saved_value_get($this->name);
						}

						if ($this->value === NULL) {
							$this->value = $this->form->hidden_value_get('h-' . $this->name);
						}

						if ($this->value !== NULL) {
							if (config::get('form.auto_clean_whitespace', false)) { // Before auto_trim, e.g. non-breaking-space is trimmed.
								$this->value = clean_whitespace($this->value);
							}
							if (config::get('form.auto_trim', true)) {
								$this->value = trim($this->value);
							}
						}

					}

				//--------------------------------------------------
				// Additional field configuration

					$this->input_type = 'text';

			}

			public function input_type_set($input_type) {
				$this->input_type = $input_type; // e.g. "tel"
			}

			public function input_size_set($input_size) {
				$this->input_size = $input_size;
			}

			public function input_mode_set($input_mode) {
				$this->input_mode = $input_mode;
			}

			public function input_list_set($options, $id = NULL) {
				if (count($options) > 0) {

					if ($id === NULL) {
						$id = $this->input_id_get() . '_list';
					}

					$this->input_list_id = $id;
					$this->input_list_options = $options;

				} else {

					$this->input_list_id = NULL;
					$this->input_list_options = NULL;

				}
			}

			public function placeholder_set($placeholder) {
				$this->placeholder = $placeholder;
			}

		//--------------------------------------------------
		// Errors

			public function min_length_set($error, $size = 1) { // Default is "required"
				$this->min_length_set_html(to_safe_html($error), $size);
			}

			public function min_length_set_html($error_html, $size = 1) {

				$error_html = str_replace('XXX', $size, $error_html);
				if ($this->form_submitted && strlen(trim(strval($this->value))) < $size) {
					$this->form->_field_error_set_html($this->form_field_uid, $error_html);
				}

				$this->min_length = $size;
				$this->required = ($size > 0);

			}

			public function max_length_set($error, $size = NULL) {
				$this->max_length_set_html(to_safe_html($error), $size);
			}

			public function max_length_set_html($error_html, $size = NULL) {

				if ($size === NULL) {

					if ($this->db_field_name === NULL) {
						exit('<p>You need to call "db_field_set", on the field "' . $this->label_html . '"</p>');
					}

					$size = intval($this->db_field_info_get('length')); // Convert NULL to 0 explicitly, always triggers error.

				}

				$error_html = str_replace('XXX', $size, $error_html);

				if ($this->form_submitted && strlen(strval($this->value)) > $size) {
					$this->form->_field_error_set_html($this->form_field_uid, $error_html);
				}

				$this->max_length = $size;

			}

		//--------------------------------------------------
		// Value

			public function value_set($value) {
				$this->value = $value;
			}

			public function value_get() {
				return $this->value;
			}

			protected function _value_print_get() {
				if ($this->value === NULL) {
					if ($this->db_field_name !== NULL) {
						$db_value = $this->db_field_value_get();
					} else {
						$db_value = NULL;
					}
					return $db_value;
				}
				return $this->value; // Don't use $this->value_get(), as fields such as currency/postcode use that function to return the clean version.
			}

			public function value_hidden_get() {
				if ($this->print_hidden) {
					return strval($this->_value_print_get()); // Cannot be NULL, as a field with print_hidden_set(true) will not get a hidden field.
				} else {
					return NULL;
				}
			}

		//--------------------------------------------------
		// Validation

			public function _post_validation() {

				parent::_post_validation();

				if ($this->max_length === NULL) {
					exit('<p>You need to call "max_length_set", on the field "' . $this->label_html . '"</p>');
				}

			}

		//--------------------------------------------------
		// Attributes

			protected function _input_attributes() {

				$attributes = parent::_input_attributes();

				if ($this->input_type !== NULL) {
					$attributes['type'] = $this->input_type;
				}

				if ($this->input_mode !== NULL) {
					$attributes['inputmode'] = $this->input_mode; // https://html.spec.whatwg.org/multipage/interaction.html#input-modalities:-the-inputmode-attribute
				}

				if ($this->input_size !== NULL) {
					$attributes['size'] = intval($this->input_size);
				}

				if ($this->input_list_id !== NULL) {
					$attributes['list'] = $this->input_list_id;
				}

				if ($this->min_length !== NULL && $this->min_length > 1) { // Value of 1 (default) is basically required
					$attributes['minlength'] = intval($this->min_length);
				}

				if ($this->max_length !== NULL && $this->max_length > 0) {
					$attributes['maxlength'] = intval($this->max_length);
				}

				if ($this->placeholder !== NULL) {
					$attributes['placeholder'] = $this->placeholder;
				}

				return $attributes;

			}

		//--------------------------------------------------
		// HTML

			public function html_input() {
				$html = $this->_html_input(['value' => strval($this->_value_print_get())]);
				if ($this->input_list_id !== NULL) {
					$html .= '<datalist id="' . html($this->input_list_id) . '">';
					foreach ($this->input_list_options as $id => $value) {
						$html .= '<option value="' . html($value) . '" />';
					}
					$html .= '</datalist>';
				}
				return $html;
			}

	}

	class form_field_textarea extends form_field_text {

		//--------------------------------------------------
		// Variables

			protected $textarea_rows = 5;
			protected $textarea_cols = 40;

		//--------------------------------------------------
		// Setup

			public function __construct($form, $label, $name = NULL) {

				//--------------------------------------------------
				// Perform the standard field setup

					$this->setup_text($form, $label, $name, 'textarea');

			}

			public function rows_set($rows) {
				$this->textarea_rows = $rows;
			}

			public function cols_set($cols) {
				$this->textarea_cols = $cols;
			}

		//--------------------------------------------------
		// Attributes

			protected function _input_attributes() {

				$attributes = parent::_input_attributes();

				unset($attributes['type']);
				unset($attributes['value']);
				unset($attributes['size']);

				$attributes['rows'] = intval($this->textarea_rows);
				$attributes['cols'] = intval($this->textarea_cols);

				return $attributes;

			}

		//--------------------------------------------------
		// HTML

			public function html_input() {
				return html_tag('textarea', $this->_input_attributes()) . html(strval($this->_value_print_get())) . '</textarea>';
			}

	}

	class form_field_url extends form_field_text {

		//--------------------------------------------------
		// Variables

			protected $format_error_set = false;
			protected $format_error_found = false;

		//--------------------------------------------------
		// Setup

			public function __construct($form, $label, $name = NULL) {

				//--------------------------------------------------
				// Perform the standard field setup

					$this->setup_text($form, $label, $name, 'url');

				//--------------------------------------------------
				// Additional field configuration

					$this->input_type = 'text'; // Not "url", as it requires a "https?://" prefix, which most people don't bother with.
					$this->input_mode = 'url';

			}

		//--------------------------------------------------
		// Errors

			public function format_error_set($error) {
				$this->format_error_set_html(to_safe_html($error));
			}

			public function format_error_set_html($error_html) {

				if ($this->form_submitted && $this->value != '') {
					$url_parts = @parse_url($this->value);
					if ($url_parts === false || !isset($url_parts['scheme']) || !isset($url_parts['host'])) {

						$this->form->_field_error_set_html($this->form_field_uid, $error_html);

						$this->format_error_found = true;

					}
				}

				$this->format_error_set = true;

			}

			public function scheme_default_set($scheme) {
				if ($this->form_submitted && $this->value != '' && !preg_match('/^[a-z]+:/i', $this->value)) {
					$this->value = $scheme . '://' . $this->value;
				}
			}

			public function scheme_allowed_set($error, $schemes) {
				$this->scheme_allowed_set_html(to_safe_html($error), $schemes);
			}

			public function scheme_allowed_set_html($error_html, $schemes) {

				if ($this->form_submitted && $this->value != '') {
					$url_parts = @parse_url($this->value);
					if (isset($url_parts['scheme']) && !in_array($url_parts['scheme'], $schemes)) {
						$this->form->_field_error_set_html($this->form_field_uid, $error_html, 'Scheme: ' . $url_parts['scheme']);
					}
				}

			}

		//--------------------------------------------------
		// Validation

			public function _post_validation() {

				parent::_post_validation();

				if ($this->format_error_set == false) {
					exit('<p>You need to call "format_error_set", on the field "' . $this->label_html . '"</p>');
				}

			}

	}

	class form_field_email extends form_field_text {

		//--------------------------------------------------
		// Variables

			protected $multiple = false;

			protected $domain_check = true;
			protected $domain_error_html = NULL;
			protected $domain_error_skip_value = '';
			protected $domain_error_skip_html = NULL;
			protected $domain_error_skip_show = false;
			protected $format_error_html = false;
			protected $format_error_set = false;
			protected $format_error_found = false;

		//--------------------------------------------------
		// Setup

			public function __construct($form, $label, $name = NULL) {

				//--------------------------------------------------
				// Perform the standard field setup

					$this->setup_text($form, $label, $name, 'email');

				//--------------------------------------------------
				// Additional field configuration

					$this->input_type = 'email';
					$this->input_mode = 'email';
					$this->autocapitalize = false;

			}

			public function check_domain_set($check_domain) {
				report_add('Deprecated: The email field check_domain_set() is being re-named to domain_check_set()', 'notice');
				$this->domain_check_set($check_domain);
			}

			public function multiple_set($multiple) {
				$this->multiple = $multiple;
			}

			public function multiple_get() {
				return $this->multiple;
			}

		//--------------------------------------------------
		// Errors

			public function domain_check_set($domain_check) {
				$this->domain_check = $domain_check;
			}

			public function domain_error_set($error, $skip_label = NULL) { // If a domain error is not set, the format error will be used (assuming the domain is checked).
				$this->domain_error_set_html(to_safe_html($error), html($skip_label));
			}

			public function domain_error_set_html($error_html, $skip_label_html = NULL) {

				$this->domain_error_html = $error_html;

				if ($skip_label_html) {
					$name = $this->name . '-DW';
					$id = $this->id . '-DW';
					$this->domain_error_skip_value = request($name, $this->form->form_method_get());
					$this->domain_error_skip_html = ' <input type="checkbox" name="' . html($name) . '" id="' . html($id) . '" value="' . html($this->value) . '"' . ($this->domain_error_skip_value == $this->value ? ' checked="checked"' : '') . ' /> <label for="' . html($id) . '">' . $skip_label_html . '</label>';
				} else {
					$this->domain_error_skip_value = '';
					$this->domain_error_skip_html = NULL;
				}

			}

			public function format_error_set($error) { // To provide an override to the domain_check, try using $field->domain_error_set('The email address does not end with a valid domain (the bit after the @ sign).', 'Skip Check?');
				$this->format_error_set_html(to_safe_html($error));
			}

			public function format_error_set_html($error_html) {
				$this->format_error_html = $error_html;
				$this->format_error_set = true;
			}

		//--------------------------------------------------
		// Validation

			public function _post_validation() {

				parent::_post_validation();

				if ($this->format_error_set == false) {
					exit('<p>You need to call "format_error_set", on the field "' . $this->label_html . '"</p>');
				}

				if ($this->form_submitted && $this->value != '') {

					if ($this->multiple) {

						$emails = array_filter(array_map('trim', explode(',', $this->value)));

						$this->value = implode(',', $emails); // Cleanup any whitespace characters (browsers generally do this automatically).

					} else {

						$emails = [$this->value];

					}

					foreach ($emails as $email) {

						$valid = is_email($email, ($this->domain_check ? -1 : false)); // -1 to return the type of failure (-1 for format, -2 for domain check)

						if ($valid !== true) {

							if ($this->domain_error_html && $valid === -2) {

								$this->domain_error_skip_show = true;

								if (!$this->domain_error_skip_html || $this->domain_error_skip_value != $email) {
									$this->form->_field_error_set_html($this->form_field_uid, $this->domain_error_html);
								}

							} else {

								$this->form->_field_error_set_html($this->form_field_uid, $this->format_error_html);

							}

						}

					}

				}

			}

		//--------------------------------------------------
		// Attributes

			protected function _input_attributes() {

				$attributes = parent::_input_attributes();

				if ($this->multiple) {
					$attributes['multiple'] = 'multiple';
				}

				return $attributes;

			}

		//--------------------------------------------------
		// HTML

			public function html_input() {
				$html = parent::html_input();
				if ($this->domain_error_skip_show) {
					$html .= $this->domain_error_skip_html;
				}
				return $html;
			}

	}

	class form_field_number extends form_field_text {

		//--------------------------------------------------
		// Variables

			protected $value_clean = NULL;

			protected $format_error_set = false;
			protected $format_error_found = false;
			protected $zero_to_blank = false;
			protected $min_value = NULL;
			protected $max_value = NULL;
			protected $step_value = 'any';

		//--------------------------------------------------
		// Setup

			public function __construct($form, $label, $name = NULL) {
				$this->setup_number($form, $label, $name, 'number');
			}

			protected function setup_number($form, $label, $name, $type) {

				//--------------------------------------------------
				// Perform the standard field setup

					$this->setup_text($form, $label, $name, $type);

				//--------------------------------------------------
				// Clean input value

					if ($this->form_submitted) {
						$this->value_set($this->value);
					}

				//--------------------------------------------------
				// Additional field configuration

					$this->input_type = 'number';
					$this->input_mode = 'decimal'; // Can be 'numeric' when using step_value_set().

			}

			public function zero_to_blank_set($blank) {
				$this->zero_to_blank = ($blank == true);
			}

		//--------------------------------------------------
		// Errors

			public function format_error_set($error) {
				$this->format_error_set_html(to_safe_html($error));
			}

			public function format_error_set_html($error_html) {

				if ($this->form_submitted && $this->value !== '' && $this->value_clean === NULL) {

					$this->form->_field_error_set_html($this->form_field_uid, $error_html);

					$this->format_error_found = true;

				}

				$this->format_error_set = true;

			}

			public function required_error_set($error) {
				$this->min_length_set_html(to_safe_html($error));
			}

			public function required_error_set_html($error_html) {
				$this->min_length_set_html($error_html);
			}

			public function min_value_set($error, $value) {
				$this->min_value_set_html(to_safe_html($error), $value);
			}

			public function min_value_set_html($error_html, $value) {

				if ($this->form_submitted && !$this->format_error_found && $this->value !== '' && $this->value_clean < $value) {
					$this->form->_field_error_set_html($this->form_field_uid, str_replace('XXX', $value, $error_html));
				}

				$this->min_value = $value;

			}

			public function max_value_set($error, $value) {
				$this->max_value_set_html(to_safe_html($error), $value);
			}

			public function max_value_set_html($error_html, $value) {

				if ($this->form_submitted && !$this->format_error_found && $this->value !== '' && $this->value_clean > $value) {
					$this->form->_field_error_set_html($this->form_field_uid, str_replace('XXX', $value, $error_html));
				}

				$this->max_value = $value;
				$this->max_length = (strlen($value) + 6); // Allow for a decimal place, plus an arbitrary 5 digits.

				if ($this->input_size === NULL && $this->max_length < 20) {
					$this->input_size = $this->max_length;
				}

			}

			public function step_value_set($error, $step = 1) {
				$this->step_value_set_html(to_safe_html($error), $step);
			}

			public function step_value_set_html($error_html, $step = 1) {

				if ($this->form_submitted && !$this->format_error_found && $this->value !== '') {

					$value = $this->value_clean;

					if ($this->min_value !== NULL) {
						$value += $this->min_value; // HTML step starts at the min value
					}

					if (abs((round($value / $step) * $step) - $value) > 0.00001) { // ref 'epsilon' on https://php.net/manual/en/language.types.float.php
						$this->form->_field_error_set_html($this->form_field_uid, str_replace('XXX', $step, $error_html));
					}

				}

				$this->step_value = $step;
				$this->input_mode = (floor($step) != $step ? 'decimal' : 'numeric');

			}

		//--------------------------------------------------
		// Value

			public function value_set($value) {
				if ($value === NULL) { // A disabled input field won't be submitted (NULL)
					$value = '';
				}
				if ($value === '') {
					$this->value_clean = 0;
				} else {
					$this->value_clean = parse_number($value);
				}
				$this->value = $value;
			}

			public function value_get() {
				if ($this->value === '') { // Allow caller to differentiate between '' and '0', so it can store no value as NULL in database.
					return '';
				} else {
					return $this->value_clean;
				}
			}

			protected function _value_print_get() {

				$value = parent::_value_print_get(); // Value from $this->value (request, saved_value, or hidden_value); or database.

				$value_clean = parse_number($value);

				if ($value_clean !== NULL) {
					if ($value_clean == 0 && $this->zero_to_blank && $this->type != 'currency') {
						return '';
					} else {
						return $value_clean;
					}
				}

				return $value;

			}

		//--------------------------------------------------
		// Validation

			public function _post_validation() {

				parent::_post_validation();

				if ($this->format_error_set == false) {
					exit('<p>You need to call "format_error_set", on the field "' . $this->label_html . '"</p>');
				}

			}

		//--------------------------------------------------
		// Attributes

			protected function _input_attributes() {

				$attributes = parent::_input_attributes();

				if ($this->min_value !== NULL) {
					$attributes['min'] = $this->min_value;
				}

				if ($this->max_value !== NULL) {
					$attributes['max'] = $this->max_value;
				}

				if ($this->step_value !== NULL && $this->input_type != 'text') { // Text is used for currency fields
					$attributes['step'] = $this->step_value;
				}

				if (isset($attributes['value']) && $attributes['value'] === '') {
					unset($attributes['value']); // HTML5 validation requires a valid floating point number, so can't be an empty string
				}

				if ($this->input_type == 'number') {
					unset($attributes['size']); // Invalid HTML5 attribute, but currency field is still text.
				}

				unset($attributes['minlength']); // Invalid HTML5 attributes
				unset($attributes['maxlength']);

				return $attributes;

			}

	}

	class form_field_password extends form_field_text {

		//--------------------------------------------------
		// Variables

			protected $passwordrules = NULL;

		//--------------------------------------------------
		// Setup

			public function __construct($form, $label, $name = NULL) {

				//--------------------------------------------------
				// Perform the standard field setup

					$this->setup_text($form, $label, $name, 'password');

				//--------------------------------------------------
				// Additional field configuration

					$this->input_type = 'password';

			}

			public function passwordrules_set($passwordrules) {

				$this->passwordrules = $passwordrules;

					// https://github.com/whatwg/html/issues/3518
					// https://developer.apple.com/password-rules/
					// https://github.com/mozilla/standards-positions/issues/61

			}

		//--------------------------------------------------
		// Attributes

			protected function _input_attributes() {

				$attributes = parent::_input_attributes();

				if ($this->passwordrules !== NULL) {
					$attributes['passwordrules'] = $this->passwordrules;
				}

				return $attributes;

			}

	}

	class form_field_postcode extends form_field_text {

		//--------------------------------------------------
		// Variables

			protected $format_country = 'UK';
			protected $format_error_set = false;
			protected $format_error_found = false;

		//--------------------------------------------------
		// Setup

			public function __construct($form, $label, $name = NULL) {

				//--------------------------------------------------
				// Perform the standard field setup

					$this->setup_text($form, $label, $name, 'postcode');

				//--------------------------------------------------
				// Additional field configuration

					$this->max_length = 8; // Bypass required "max_length_set" call, and to set the <input maxlength="" />

			}

		//--------------------------------------------------
		// Errors

			public function format_country_set($country) {
				$this->format_country = $country;
			}

			public function format_error_set($error) {
				$this->format_error_set_html(to_safe_html($error));
			}

			public function format_error_set_html($error_html) {

				if ($this->form_submitted && $this->value != '') {
					$postcode_clean = format_postcode($this->value, $this->format_country);
					if ($postcode_clean === NULL) {

						$this->form->_field_error_set_html($this->form_field_uid, $error_html);

						$this->format_error_found = true;

					}
				}

				$this->format_error_set = true;

			}

			public function required_error_set($error) {
				$this->required_error_set_html(to_safe_html($error));
			}

			public function required_error_set_html($error_html) {

				if ($this->form_submitted && $this->value == '') {
					$this->form->_field_error_set_html($this->form_field_uid, $error_html);
				}

				$this->required = ($error_html !== NULL);

			}

		//--------------------------------------------------
		// Value

			public function value_get() {
				$value = format_postcode($this->value, $this->format_country);
				return ($value === NULL ? '' : $value); // If the value is an empty string (or error), it should return an empty string, so changes can be detected with new_value !== old_value
			}

			public function value_raw_get() {
				return $this->value;
			}

		//--------------------------------------------------
		// Validation

			public function _post_validation() {

				parent::_post_validation();

				if ($this->format_error_set == false) {
					exit('<p>You need to call "format_error_set", on the field "' . $this->label_html . '"</p>');
				}

			}

	}

	class form_field_telephone extends form_field_text {

		//--------------------------------------------------
		// Variables

			protected $format_error_set = false;
			protected $format_error_found = false;

		//--------------------------------------------------
		// Setup

			public function __construct($form, $label, $name = NULL) {

				//--------------------------------------------------
				// Perform the standard field setup

					$this->setup_text($form, $label, $name, 'telephone');

				//--------------------------------------------------
				// Additional field configuration

					$this->input_type = 'tel'; // Still allow characters (e.g. "0000 000000 Ext 00")
					$this->input_mode = 'tel';

			}

		//--------------------------------------------------
		// Errors

			public function format_error_set($error) {
				$this->format_error_set_html(to_safe_html($error));
			}

			public function format_error_set_html($error_html) {

				if ($this->form_submitted && $this->value != '') {
					$telephone_clean = format_telephone_number($this->value);
					if ($telephone_clean === NULL) {

						$this->form->_field_error_set_html($this->form_field_uid, $error_html);

						$this->format_error_found = true;

					}
				}

				$this->format_error_set = true;

			}

			public function required_error_set($error) {
				$this->required_error_set_html(to_safe_html($error));
			}

			public function required_error_set_html($error_html) {

				if ($this->form_submitted && $this->value == '') {
					$this->form->_field_error_set_html($this->form_field_uid, $error_html);
				}

				$this->required = ($error_html !== NULL);

			}

		//--------------------------------------------------
		// Value

			public function value_get() {
				$value = format_telephone_number($this->value);
				return ($value === NULL ? '' : $value); // If the value is an empty string (or error), it should return an empty string, so changes can be detected with new_value !== old_value
			}

		//--------------------------------------------------
		// Validation

			public function _post_validation() {

				parent::_post_validation();

				if ($this->format_error_set == false) {
					exit('<p>You need to call "format_error_set", on the field "' . $this->label_html . '"</p>');
				}

			}

	}

	class form_field_currency extends form_field_number {

		//--------------------------------------------------
		// Variables

			protected $currency_char = '£';
			protected $trim_decimal = false;

		//--------------------------------------------------
		// Setup

			public function __construct($form, $label, $name = NULL) {

				//--------------------------------------------------
				// Perform the standard field setup

					$this->setup_number($form, $label, $name, 'currency');

				//--------------------------------------------------
				// Additional field configuration

					$this->step_value = NULL;
					$this->input_type = 'text'; // Not type="number", from number field

			}

			public function currency_char_set($char) {
				$this->currency_char = $char;
			}

			public function trim_decimal_set($trim) {
				$this->trim_decimal = $trim;
			}

		//--------------------------------------------------
		// Errors

			public function min_value_set_html($error_html, $value) {

				$value = floatval($value);

				if ($this->form_submitted && !$this->format_error_found && $this->value !== '' && $this->value_clean < $value) {

					if ($value < 0) {
						$value_text = '-' . $this->currency_char . number_format((0 - $value), 2);
					} else {
						$value_text = $this->currency_char . number_format($value, 2);
					}

					$this->form->_field_error_set_html($this->form_field_uid, str_replace('XXX', $value_text, $error_html));

				}

				$this->min_length = $value;

			}

			public function max_value_set_html($error_html, $value) {

				$value = floatval($value);

				if ($this->form_submitted && !$this->format_error_found && $this->value !== '' && $this->value_clean > $value) {

					if ($value < 0) {
						$value_text = '-' . $this->currency_char . number_format((0 - $value), 2);
					} else {
						$value_text = $this->currency_char . number_format($value, 2);
					}

					$this->form->_field_error_set_html($this->form_field_uid, str_replace('XXX', $value_text, $error_html));

				}

				$this->max_length  = strlen(floor($value));
				$this->max_length += (intval($this->min_length) < 0 ? 1 : 0); // Negative numbers
				$this->max_length += (function_exists('mb_strlen') ? mb_strlen($this->currency_char, config::get('output.charset')) : strlen($this->currency_char));
				$this->max_length += (floor((strlen(floor($value)) - 1) / 3)); // Thousand separators
				$this->max_length += 3; // Decimal place char, and 2 digits

				if ($this->input_size === NULL && $this->max_length < 20) {
					$this->input_size = $this->max_length;
				}

			}

		//--------------------------------------------------
		// Value

			protected function _value_print_get() {

				$value = parent::_value_print_get(); // form_field_number will try to use parse_number() to return an int or float.

				if ($this->trim_decimal && fmod($value, 1) == 0) {
					$decimal_places = 0;
				} else {
					$decimal_places = ($this->step_value == 1 ? 0 : 2);
				}

				if (is_int($value) || is_float($value)) {
					return format_currency($value, $this->currency_char, $decimal_places, $this->zero_to_blank);
				} else {
					return $value;
				}

			}

	}

	class form_field_checkbox extends form_field_text {

		//--------------------------------------------------
		// Variables

			protected $text_value_true = NULL;
			protected $text_value_false = NULL;

		//--------------------------------------------------
		// Setup

			public function __construct($form, $label, $name = NULL) {

				//--------------------------------------------------
				// Perform the standard field setup

					$this->setup_text($form, $label, $name, 'check');

				//--------------------------------------------------
				// Value

					if ($this->form_submitted) {
						$this->value = ($this->value == 'true');
					}

				//--------------------------------------------------
				// Additional field configuration

					$this->max_length = -1; // Bypass the _post_validation on the text field (not used)
					$this->input_type = 'checkbox';

			}

			public function text_values_set($true, $false) {
				$this->text_value_true = $true;
				$this->text_value_false = $false;
			}

		//--------------------------------------------------
		// Errors

			public function required_error_set($error) {
				$this->required_error_set_html(to_safe_html($error));
			}

			public function required_error_set_html($error_html) {

				if ($this->form_submitted && $this->value !== true) {
					$this->form->_field_error_set_html($this->form_field_uid, $error_html);
				}

				$this->required = ($error_html !== NULL);

			}

		//--------------------------------------------------
		// Value

			public function value_set($value) {
				if ($this->text_value_true !== NULL) {
					$this->value = ($value == $this->text_value_true);
				} else {
					$this->value = ($value == true);
				}
			}

			public function value_get() {
				if ($this->text_value_true !== NULL) {
					return ($this->value ? $this->text_value_true : $this->text_value_false);
				} else {
					return $this->value;
				}
			}

			protected function _value_print_get() {
				if ($this->value === NULL) {
					$true_value = ($this->text_value_true !== NULL ? $this->text_value_true : true);
					if ($this->db_field_name !== NULL) {
						$db_value = $this->db_field_value_get();
					} else {
						$db_value = NULL;
					}
					return ($true_value == $db_value);
				}
				return $this->value;
			}

			public function value_hidden_get() {
				if ($this->print_hidden) {
					return ($this->value ? 'true' : 'false');
				} else {
					return NULL;
				}
			}

		//--------------------------------------------------
		// HTML

			public function html_input() {

				$attributes = array(
						'value' => 'true',
					);

				if ($this->_value_print_get()) {
					$attributes['checked'] = 'checked';
				}

				return $this->_html_input($attributes);

			}

	}

	class form_field_checkboxes extends form_field_select {

		//--------------------------------------------------
		// Variables

			protected $value_print_cache = NULL;
			protected $option_values_html = [];
			protected $options_group_id = [];
			protected $options_info_id = [];
			protected $options_info_html = NULL;
			protected $options_disabled = NULL;
			protected $options_suffix_html = NULL;

		//--------------------------------------------------
		// Setup

			public function __construct($form, $label, $name = NULL) {

				//--------------------------------------------------
				// Perform the standard field setup

					$this->setup_select($form, $label, $name, 'checkboxes');

				//--------------------------------------------------
				// Additional field configuration

					$this->multiple = true; // So functions like value_get will return all items

			}

			public function options_set_html($options_html) { // If you are adding links, consider options_info_set()
				$this->options_set(array_map('html_decode', array_map('strip_tags', $options_html)));
				$this->option_values_html = $options_html;
			}

			public function option_set_html($ref, $value_html) {
				$this->option_values_html[$ref] = $value_html;
			}

			public function options_info_set($options_info) {
				$this->options_info_set_html(array_map('to_safe_html', $options_info));
			}

			public function options_info_set_html($options_info_html) {
				$this->options_info_html = $options_info_html;
			}

			public function options_suffix_set($options_suffix) {
				$this->options_suffix_set_html(array_map('to_safe_html', $options_suffix));
			}

			public function options_suffix_set_html($options_suffix_html) {
				$this->options_suffix_html = $options_suffix_html;
			}

			public function options_disabled_set($options_disabled) {
				$this->options_disabled = $options_disabled;
			}

		//--------------------------------------------------
		// Field ID

			public function field_id_by_value_get($value) {
				$key = array_search($value, $this->option_values);
				if ($key !== false && $key !== NULL) {
					return $this->field_id_by_key_get($key);
				} else {
					return 'Unknown value "' . html($value) . '"';
				}
			}

			public function field_id_by_key_get($key) {
				if ($key === NULL) {
					return $this->id; // Label
				} else {
					return $this->id . '_' . human_to_ref($key);
				}
			}

			public function input_first_id_get() {
				reset($this->option_values);
				return $this->field_id_by_key_get(key($this->option_values));
			}

		//--------------------------------------------------
		// HTML label

			public function html_label($label_html = NULL) {

				if ($label_html === NULL) {
					$label_html = parent::html_label();
					$label_html = preg_replace('/<label[^>]+>(.*)<\/label>/', '$1', $label_html); // Ugly, but better than duplication
				}

				$tag_id = $this->form->_field_tag_id_get(); // For "aria-describedby"

				array_unshift($this->input_described_by, $tag_id); // Label comes first

				return '<span id="' . html($tag_id) . '">' . $label_html . '</span>'; // No, you still can't have multiple labels for an input

			}

			public function html_label_by_value($value, $label_html = NULL) {
				$key = array_search($value, $this->option_values);
				if ($key !== false && $key !== NULL) {
					return $this->html_label_by_key($key, $label_html);
				} else {
					return 'Unknown value "' . html($value) . '"';
				}
			}

			public function html_label_by_key($key, $label_html = NULL, $label_tag = NULL) {

				if ($key !== NULL && !isset($this->option_values[$key])) {
					return 'Unknown key "' . html($key) . '"';
				}

				$input_id = $this->field_id_by_key_get($key);

				if ($label_html === NULL) {

					if ($key === NULL) {
						$label_html = to_safe_html($this->label_option);
					} else if (isset($this->option_values_html[$key])) {
						$label_html = $this->option_values_html[$key];
					} else {
						$label_html = to_safe_html($this->option_values[$key]);
					}

					$function = $this->form->label_override_get_function();
					if ($function !== NULL) {
						$label_html = call_user_func($function, $label_html, $this->form, $this);
					}

				}

				if ($label_tag == 'span') {
					return '<span' . ($this->label_class === NULL ? '' : ' class="' . html($this->label_class) . '"') . '>' . $label_html . '</span>';
				} else {
					return '<label for="' . html($input_id) . '"' . ($this->label_class === NULL ? '' : ' class="' . html($this->label_class) . '"') . '>' . $label_html . '</label>';
				}

			}

		//--------------------------------------------------
		// HTML input

			public function html_input() {

				$label_tag = ($this->input_wrapper_tag == 'label' ? 'span' : NULL);

				if ($this->label_option != '') { // Could be NULL or ''
					$label_html = '
							<' . html($this->input_wrapper_tag) . ' class="' . html($this->input_wrapper_class) . ' input_label">
								' . $this->html_input_by_key(NULL) . '
								' . $this->html_label_by_key(NULL, NULL, $label_tag) . '
							</' . html($this->input_wrapper_tag) . '>';
				} else {
					$label_html = '';
				}

				if ($this->options_group !== NULL) {
					foreach (array_unique($this->options_group) as $opt_group) {
						$this->options_group_id[$opt_group] = $this->form->_field_tag_id_get();
					}
				}

				$option_html = [];
				foreach ($this->option_values as $key => $value) {
					$option_info_html = $this->html_info_by_key($key); // Allow the ID to be added to "aria-describedby"
					$option_disabled = (isset($this->options_disabled[$key]) && $this->options_disabled[$key] === true);
					$option_html[$key] = '
							<' . html($this->input_wrapper_tag) . ' class="' . html($this->input_wrapper_class) . ' ' . html('key_' . human_to_ref($key)) . ' ' . html('value_' . human_to_ref($value)) . ($option_disabled ? ' option_disabled' : '') . '">
								' . $this->html_input_by_key($key) . '
								' . $this->html_label_by_key($key, NULL, $label_tag) . $option_info_html . '
							</' . html($this->input_wrapper_tag) . '>';
					if (isset($this->options_suffix_html[$key])) {
						$option_html[$key] .= $this->options_suffix_html[$key];
					}
				}

				$used_keys = [];
				$group_html = '';

				if ($this->options_group !== NULL) {
					foreach (array_unique($this->options_group) as $opt_group) {

						if ($opt_group !== NULL) {
							$group_html .= '
								<fieldset class="optgroup">
									<legend id="' . html($this->options_group_id[$opt_group]) . '">' . html($opt_group) . '</legend>';
						}

						foreach (array_keys($this->options_group, $opt_group) as $key) {
							if ($key === '') {

								$group_html .= $label_html;

								$label_html = '';

							} else if (isset($option_html[$key])) {

								$used_keys[] = $key;

								$group_html .= $option_html[$key];

							}
						}

						if ($opt_group !== NULL) {
							$group_html .= '
								</fieldset>';
						}

					}
				}

				$html = $label_html;

				foreach ($this->option_values as $key => $value) {
					if (!in_array($key, $used_keys)) {
						$html .= $option_html[$key];
					}
				}

				$html .= $group_html;

				return $html;

			}

			public function html_input_by_value($value) {
				$key = array_search($value, $this->option_values);
				if ($key !== false && $key !== NULL) {
					return $this->html_input_by_key($key);
				} else {
					return 'Unknown value "' . html($value) . '"';
				}
			}

			public function html_input_by_key($key) {

				if ($key !== NULL && !isset($this->option_values[$key])) {
					return 'Unknown key "' . html($key) . '"';
				}

				$attributes = $this->_input_by_key_attributes($key);

				if (isset($this->options_info_id[$key])) {
					$attributes['aria-describedby'] .= ' ' . $this->options_info_id[$key];
				}

				$group_ref = ($this->options_group[$key] ?? NULL);
				if ($group_ref && isset($this->options_group_id[$group_ref])) {
					$attributes['aria-describedby'] .= ' ' . $this->options_group_id[$group_ref]; // Should already be set, so append is ok.
				}

				return html_tag('input', $attributes);

			}

			public function _input_by_key_attributes($key) {

				if ($this->value_print_cache === NULL) {
					$this->value_print_cache = $this->_value_print_get(); // form_field_select always returns an array (not NULL)
				}

				$attributes = ($this->options_attributes[$key] ?? []); // Takes least precedence
				$attributes = array_merge($attributes, parent::_input_attributes());

				$attributes['type'] = 'checkbox';
				$attributes['id'] = $this->field_id_by_key_get($key);
				$attributes['name'] = $this->name . '[]';
				$attributes['value'] = ($key === NULL ? '' : $key);
				$attributes['required'] = NULL; // Can't set to required, as otherwise you have to tick all of them.

				if (isset($this->options_disabled[$key]) && $this->options_disabled[$key] === true) {
					$attributes['disabled'] = 'disabled';
				}

				if (isset($this->options_class[$key])) {
					$attributes['class'] = $this->options_class[$key];
				}

				if (in_array($attributes['value'], $this->value_print_cache)) {
					$attributes['checked'] = 'checked';
				}

				reset($this->option_values);
				if (key($this->option_values) != $key) {
					unset($attributes['autofocus']);
				}

				return $attributes;

			}

			public function html_info_by_key($key) {
				if (isset($this->options_info_html[$key])) {
					$id = $this->form->_field_tag_id_get();
					$this->options_info_id[$key] = $id;
					return '
									<span class="info" id="' . html($id) . '">' . $this->options_info_html[$key] . '</span>';
				} else {
					return '';
				}
			}

	}

	class form_field_radios extends form_field_checkboxes {

		//--------------------------------------------------
		// Setup

			public function __construct($form, $label, $name = NULL) {

				//--------------------------------------------------
				// Perform the select field setup

					$this->setup_select($form, $label, $name, 'radios');

			}

		//--------------------------------------------------
		// Validation

			public function _post_validation() {

				parent::_post_validation();

				if ($this->required_error_set == false && $this->label_option === NULL) {
					exit('<p>You need to call "required_error_set" or "label_option_set", on the field "' . $this->label_html . '"</p>');
				}

			}

		//--------------------------------------------------
		// HTML input

			public function _input_by_key_attributes($key) {

				if ($this->value_print_cache === NULL) {
					$this->value_print_cache = $this->_value_print_get(); // form_field_select always returns an array (not NULL)
				}

				$attributes = ($this->options_attributes[$key] ?? []); // Takes least precedence
				$attributes = array_merge($attributes, parent::_input_attributes());
				$attributes['type'] = 'radio';
				$attributes['id'] = $this->field_id_by_key_get($key);
				$attributes['value'] = ($key === NULL ? '' : $key);

				if (isset($this->options_disabled[$key]) && $this->options_disabled[$key] === true) {
					$attributes['disabled'] = 'disabled';
				}

				if (isset($this->options_class[$key])) {
					$attributes['class'] = $this->options_class[$key];
				}

				$checked = in_array($attributes['value'], $this->value_print_cache); // Cannot be a strict check, as an ID from the db may be a string or int.

				if ($key === NULL && count($this->value_print_cache) == 0) {
					$checked = true;
				}

				if ($checked) {
					$attributes['checked'] = 'checked';
				}

				return $attributes;

			}

	}

	class form_field_select extends form_field {

		//--------------------------------------------------
		// Variables

			protected $values = NULL;
			protected $multiple = false;
			protected $label_option = NULL;
			protected $option_values = [];
			protected $options_group = NULL;
			protected $options_class = NULL;
			protected $options_attributes = NULL;
			protected $db_field_options = NULL;
			protected $select_size = 1;
			protected $required_error_set = false;
			protected $invalid_error_set = false;

		//--------------------------------------------------
		// Setup

			public function __construct($form, $label, $name = NULL) {
				$this->setup_select($form, $label, $name, 'select');
			}

			protected function setup_select($form, $label, $name, $type) {

				//--------------------------------------------------
				// Perform the standard field setup

					$this->setup($form, $label, $name, $type);

				//--------------------------------------------------
				// Value

					$this->values = NULL; // Array of selected key(s), or NULL when not set

					if ($this->form_submitted || $this->form->saved_values_available()) {

						if ($this->form_submitted) {
							$this->values = request($this->name, $this->form->form_method_get());
						} else {
							$this->values = $this->form->saved_value_get($this->name);
						}

						if ($this->values === NULL) {
							$this->values = $this->form->hidden_value_get('h-' . $this->name);
							if ($this->values !== NULL) {
								$this->values = json_decode($this->values, true); // associative array
							} else if ($this->form_submitted) {
								$this->values = []; // Form submitted, but no checkboxes ticked, so REQUEST data is NULL.
							}
						}

						if ($this->values !== NULL) {

							if (!is_array($this->values)) {
								$this->values = array($this->values); // Normal (non-multiple) select field
							}

							while (($key = array_search('', $this->values, true)) !== false) { // Remove the label, which is an empty string (strict type check required)
								unset($this->values[$key]);
							}

						}

					}

			}

			public function db_field_set($a, $b = NULL, $c = NULL) {

				//--------------------------------------------------
				// Checks

					if ($this->invalid_error_set) {
						exit_with_error('Cannot call db_field_set() after invalid_error_set()');
					}

				//--------------------------------------------------
				// Set field

					$this->_db_field_set($a, $b, $c);

				//--------------------------------------------------
				// Options

					$config = $this->db_field_info_get();

					if ($config && ($config['type'] == 'enum' || $config['type'] == 'set')) {

						$options = $config['options'];

						while (($key = array_search('', $options)) !== false) { // If you want a blank option, use label_option_set, and remove the required_error.
							unset($options[$key]);
						}

						$this->db_field_options = $options;
						$this->option_values = array_combine($options, $options);

					}

			}

			public function db_field_options_get() {
				return $this->db_field_options;
			}

			public function multiple_set($multiple) {
				$this->multiple = $multiple;
			}

			public function multiple_get() {
				return $this->multiple;
			}

			public function label_option_set($text = NULL) {
				$this->label_option = $text;
			}

			public function options_set($options) {
				if ($this->invalid_error_set) {
					exit_with_error('Cannot call options_set() after invalid_error_set()');
				}
				if (in_array('', array_keys($options), true)) { // Performs a strict check (allowing id 0)
					exit_with_error('Cannot have an option with a blank key, use label_option_set() instead.', debug_dump($options));
				}
				if ($this->db_field_options !== NULL) {
					$diff = array_diff(($this->db_field_key ? array_keys($options) : $options), $this->db_field_options);
					if (count($diff) > 0) {
						exit_with_error('Invalid option ' . ($this->db_field_key ? 'key' : 'value') . (count($diff) == 1 ? '' : 's') . ' "' . implode('", "', $diff) . '" (not allowed in the database).', debug_dump($this->db_field_options));
					}
				}
				$this->option_values = $options;
			}

			public function option_values_set($values) {
				$this->options_set(array_combine($values, $values));
			}

			public function options_get() {
				return $this->option_values;
			}

			public function options_group_set($options_group) {
				$this->options_group = $options_group;
			}

			public function options_class_set($options_class) {
				$this->options_class = $options_class;
			}

			public function options_attributes_set($options_attributes) {
				$this->options_attributes = $options_attributes;
			}

			public function select_size_set($size) {
				$this->select_size = $size;
			}

		//--------------------------------------------------
		// Legacy

			public function option_groups_set($options_group) { // Use options_group_set(), to be consistent with options_class_set()
				$this->options_group_set($options_group);
			}

		//--------------------------------------------------
		// Errors

			public function required_error_set($error) {
				$this->required_error_set_html(to_safe_html($error));
			}

			public function required_error_set_html($error_html) {

				if ($this->form_submitted && ($this->values == NULL || count($this->values) == 0)) {
					$this->form->_field_error_set_html($this->form_field_uid, $error_html);
				}

				$this->required = ($error_html !== NULL);
				$this->required_error_set = true; // So the radios field can complain if not set.

			}

			public function invalid_error_set($error) {
				$this->invalid_error_set_html(to_safe_html($error));
			}

			public function invalid_error_set_html($error_html) {

				if ($this->form_submitted && $this->values !== NULL) {
					foreach ($this->values as $key) {
						if (!isset($this->option_values[$key])) {
							$this->form->_field_error_set_html($this->form_field_uid, $error_html);
							break;
						}
					}
				}

				$this->invalid_error_set = true;

			}

		//--------------------------------------------------
		// Value set

			public function value_set($value) {
				$this->values_set(array($value));
			}

			public function values_set($values) {
				$this->values = [];
				foreach ($values as $value) {
					$key = array_search($value, $this->option_values);
					if ($key !== false && $key !== NULL) {
						$this->values[] = $key;
					}
				}
			}

			public function value_key_set($key) {
				$this->value_keys_set(array($key));
			}

			public function value_keys_set($keys) {
				$this->values = [];
				foreach ($keys as $key) {
					if ($key !== '' && isset($this->option_values[$key])) {
						$this->values[] = $key;
					}
				}
			}

		//--------------------------------------------------
		// Value get

			public function value_get() {
				$values = $this->values_get();
				if ($this->multiple) {
					return implode(',', $values); // Match value_key_get behaviour
				} else {
					return array_pop($values); // Returns NULL if label is selected
				}
			}

			public function values_get() {
				$return = [];
				foreach ($this->value_keys_get() as $key) {
					$return[$key] = $this->option_values[$key];
				}
				return $return;
			}

			public function value_key_get() {
				$keys = $this->value_keys_get();
				if ($this->multiple) {
					return implode(',', $keys); // Behaviour expected for a MySQL 'set' field, where a comma is not a valid character.
				} else {
					return array_pop($keys); // Returns NULL if label is selected
				}
			}

			public function value_keys_get() {

				$return = [];

				if ($this->values !== NULL) {
					foreach ($this->values as $key) {
						if ($key !== '' && isset($this->option_values[$key])) {
							$return[] = $key; // Can't preserve type from option_values (using array_search), as an array with id 0 and string values (e.g. "X"), it would match the first one, as ["X" == 0]
						}
					}
				}

				return $return;

			}

			protected function _value_print_get() {
				if ($this->values !== NULL) {

					$values = $this->values;

				} else if ($this->db_field_name !== NULL) {

					$db_values = $this->db_field_value_get();

					if ($this->multiple) {
						$db_values = explode(',', strval($db_values)); // Commas are not valid characters in enum/set fields.
					} else {
						$db_values = array($db_values);
					}

					$values = [];

					if ($this->db_field_key) {
						foreach ($db_values as $key) {
							if (isset($this->option_values[$key])) {
								$values[] = $key;
							}
						}
					} else {
						foreach ($db_values as $value) {
							$key = array_search($value, $this->option_values);
							if ($key !== false && $key !== NULL) {
								$values[] = $key;
							}
						}
					}

				} else {

					$values = [];

				}
				return $values;
			}

			public function value_hidden_get() {
				if ($this->print_hidden) {
					$values = $this->_value_print_get();
					if ($values === NULL && $this->label_option === NULL && !$this->multiple && count($this->option_values) > 0) {
						$values = array(key($this->option_values)); // Don't have a value or label, match browser behaviour of automatically selecting first item.
					}
					return json_encode($values);
				} else {
					return NULL;
				}
			}

		//--------------------------------------------------
		// Validation

			public function _post_validation() {

				parent::_post_validation();

				if ($this->invalid_error_set == false) {
					$this->invalid_error_set('An invalid option has been selected for "' . strtolower($this->label_html) . '"');
				}

			}

		//--------------------------------------------------
		// Attributes

			protected function _input_attributes() {

				$attributes = parent::_input_attributes();

				if ($this->type == 'select') {

					if ($this->select_size > 1) {
						$attributes['size'] = intval($this->select_size);
					}

					if ($this->multiple) {
						$attributes['name'] .= '[]';
						$attributes['multiple'] = 'multiple';
					}

				}

				return $attributes;

			}

		//--------------------------------------------------
		// HTML

			public function html_input() {

				//--------------------------------------------------
				// Values

					$print_values = $this->_value_print_get();

					if (!$this->multiple && count($print_values) > 1) { // Don't have multiple selected options, when not a multiple field
						$print_values = array_slice($print_values, 0, 1);
					}

					if ($this->label_option !== NULL && $this->select_size == 1 && !$this->multiple) {
						$label_class = ($this->options_class[''] ?? '');
						$label_html = '
										<option value=""' . ($label_class ? ' class="' . html($label_class) . '"' : '') . '>' . ($this->label_option === '' ? '&#xA0;' : html($this->label_option)) . '</option>'; // Value must be blank for HTML5
					} else {
						$label_html = '';
					}

				//--------------------------------------------------
				// Group HTML

					$used_keys = [];
					$group_html = '';

					if ($this->options_group !== NULL) {
						foreach (array_unique($this->options_group) as $opt_group) {

							if ($opt_group !== NULL) {
								$group_html .= '
										<optgroup label="' . html($opt_group) . '">';
							}

							foreach (array_keys($this->options_group, $opt_group) as $key) {
								if ($key === '') {

									$group_html .= $label_html;

									$label_html = '';

								} else if (isset($this->option_values[$key])) {

									$used_keys[] = $key;

									$value = $this->option_values[$key];

									$attributes = ($this->options_attributes[$key] ?? []);
									$attributes['value'] = $key;
									if (in_array($key, $print_values)) {
										$attributes['selected'] = 'selected';
									}
									if (isset($this->options_class[$key])) {
										$attributes['class'] = $this->options_class[$key];
									}

									$group_html .= '
											' . html_tag('option', $attributes) . ($value === '' ? '&#xA0;' : html($value)) . '</option>';

								}
							}

							if ($opt_group !== NULL) {
								$group_html .= '
										</optgroup>';
							}

						}
					}

				//--------------------------------------------------
				// Main HTML

					$html = '
									' . html_tag('select', $this->_input_attributes()) . $label_html;

					foreach ($this->option_values as $key => $value) {
						if (!in_array($key, $used_keys)) {

								// Cannot do strict check with in_array() as an ID from the db may be a string or int.

							$attributes = ($this->options_attributes[$key] ?? []);
							$attributes['value'] = $key;
							if (in_array($key, $print_values)) {
								$attributes['selected'] = 'selected';
							}
							if (isset($this->options_class[$key])) {
								$attributes['class'] = $this->options_class[$key];
							}

							$html .= '
										' . html_tag('option', $attributes) . ($value === '' ? '&#xA0;' : html($value)) . '</option>';

						}
					}

					$html .= $group_html . '
									</select>' . "\n\t\t\t\t\t\t\t\t";

				//--------------------------------------------------
				// Return

					return $html;

			}

	}

	class form_field_file extends form_field {

		//--------------------------------------------------
		// Variables

			protected $multiple = false;
			protected $max_size = 0;

			protected $empty_file_error_set = false;
			protected $partial_file_error_set = false;
			protected $blank_name_error_set = false;
			protected $long_name_error_set = false;

			protected $files = [];
			protected $file_current = 0;
			protected $file_accept_mime = NULL;
			protected $file_accept_ext = NULL;
			protected $uploaded = NULL;

		//--------------------------------------------------
		// Setup

			public function __construct($form, $label, $name = NULL) {
				$this->setup_file($form, $label, $name, 'file');
			}

			protected function setup_file($form, $label, $name, $type) {

				//--------------------------------------------------
				// Perform the standard field setup

					$this->setup($form, $label, $name, $type);

				//--------------------------------------------------
				// First file field

					$this->form->_field_setup_file();

				//--------------------------------------------------
				// Newly uploaded files

					$this->files = [];

					if ($this->form_submitted && isset($_FILES[$this->name]['error']) && is_array($_FILES[$this->name]['error'])) {
						foreach ($_FILES[$this->name]['error'] as $key => $error) {
							$file_info = $this->file_store($this->name, $key);
							if ($file_info) {

								$file_info['preserve'] = ($file_info['hash'] !== NULL);

								$this->files[] = $file_info;

							}
						}
					}

				//--------------------------------------------------
				// Hidden files

					if (count($this->files) == 0) { // If user uploaded one or more files, assume it is to replace.

						if ($this->form_submitted) {
							$hidden_files = $this->form->hidden_value_get('h-' . $this->name);
						} else if ($this->form->saved_values_available()) {
							$hidden_files = $this->form->saved_value_get('h-' . $this->name); // Looks for hidden field, saved_values does not include $_FILES
							if ($hidden_files !== NULL) {
								$hidden_files = urldecode($hidden_files);
							}
						} else {
							$hidden_files = NULL;
						}

						if ($hidden_files !== NULL) {
							foreach (explode('-', $hidden_files) as $file_hash) {
								$file_info = $this->file_info($file_hash);
								if ($file_info) {

									$file_info['preserve'] = true;

									$this->files[] = $file_info;

								}
							}
						}

					}

				//--------------------------------------------------
				// Config

					$this->uploaded = (count($this->files) > 0); // Shortcut
					$this->file_current = 0;

			}

			public function multiple_set($multiple) {
				$this->multiple = $multiple;
			}

			public function multiple_get() {
				return $this->multiple;
			}

			public function placeholder_set($placeholder) {
				$this->placeholder = $placeholder;
			}

		//--------------------------------------------------
		// Storing files support

			public static function file_store($file_name, $file_offset = NULL) {

				//--------------------------------------------------
				// Return details

					if (!isset($_FILES[$file_name]['error'])) {

						return NULL;

					} else if (!is_array($_FILES[$file_name]['error'])) {

						$file_info = $_FILES[$file_name];
						$file_offset = NULL;

					} else {

						if ($file_offset === NULL) {

							$file_offset = key($_FILES[$file_name]['error']);

						} else if (!isset($_FILES[$file_name]['error'][$file_offset])) {

							return NULL;

						}

						$file_info = [];
						foreach ($_FILES[$file_name] as $key => $value) {
							$file_info[$key] = $value[$file_offset];
						}

					}

				//--------------------------------------------------
				// File not uploaded

					if ($file_info['error'] == 4) { // 4 = No file was uploaded (UPLOAD_ERR_NO_FILE)
						return NULL;
					}

				//--------------------------------------------------
				// Extension

					$file_ext = pathinfo($file_info['name'], PATHINFO_EXTENSION);
					if ($file_ext) {
						$file_info['ext'] = strtolower($file_ext);
					} else {
						$file_info['ext'] = ''; // File name missing value, which is not the same as NULL (useful distinction when sending to DB)
					}

				//--------------------------------------------------
				// Mime - backwards computability

					$file_info['mime'] = $file_info['type'];

					unset($file_info['type']);

				//--------------------------------------------------
				// Store

					if ($file_info['error'] == 0) {

						if (!is_uploaded_file($file_info['tmp_name'])) {
							exit_with_error('Only "uploaded" files can be processed with form_field_file', 'Path: ' . $file_info['tmp_name']);
						}

						$file_hash = hash('sha256', $file_info['tmp_name']); // Temp name should be unique, and faster than hasing the contents of the whole file.
						$file_path = form_field_file::_file_tmp_folder() . '/' . $file_hash;

						move_uploaded_file($file_info['tmp_name'], $file_path);

						unset($file_info['tmp_name']);

						$file_info['hash'] = $file_hash;
						$file_info['path'] = $file_path;

						file_put_contents($file_path . '.json', json_encode($file_info));

						$config_name = $file_name . ($file_offset !== NULL ? '[' . $file_offset . ']' : '');

						config::array_set('request.file_paths', $config_name, $file_path);

					} else {

						$file_info['hash'] = NULL;
						$file_info['path'] = NULL;

					}

				//--------------------------------------------------
				// Return

					return $file_info;

			}

			public static function file_info($file_hash) {

				$info_path = form_field_file::_file_tmp_folder() . '/' . $file_hash . '.json';

				if (is_file($info_path)) {
					return json_decode(file_get_contents($info_path), true); // As an array
				} else {
					return NULL;
				}

			}

			public static function _file_tmp_folder() {

				$tmp_folder = config::get('form.file_tmp_folder'); // Cached value, so old file check is only done once.

				if ($tmp_folder === NULL) {

					$tmp_folder = tmp_folder('form-file');

					unlink_old_files($tmp_folder, strtotime('-1 hour'));

					config::set('form.file_tmp_folder', $tmp_folder);

				}

				return $tmp_folder;

			}

		//--------------------------------------------------
		// Errors

			public function required_error_set($error) {
				$this->required_error_set_html(to_safe_html($error));
			}

			public function required_error_set_html($error_html) {

				if ($this->form_submitted && !$this->uploaded) {
					$this->form->_field_error_set_html($this->form_field_uid, $error_html);
				}

				$this->required = ($error_html !== NULL);

			}

			public function max_size_set($error, $size = NULL) {
				$this->max_size_set_html(to_safe_html($error), $size);
			}

			public function max_size_set_html($error_html, $size = NULL) {

				//--------------------------------------------------
				// Max size server will allow

					$this->max_size = 0;

					$server_max = $this->max_size_get();

				//--------------------------------------------------
				// Set

					if ($size === NULL) {
						if ($server_max > 0) {
							$this->max_size = intval($server_max);
						} else {
							exit_with_error('Cannot determine max file upload size for this server.');
						}
					} else {
						if ($server_max == 0 || $size <= $server_max) {
							$this->max_size = intval($size);
						} else {
							exit_with_error('The maximum file size the server accepts is "' . format_bytes($server_max) . '" (' . $server_max . '), not "' . format_bytes($size) . '" (' . $size . ')', 'upload_max_filesize = ' . ini_get('upload_max_filesize') . "\n" . 'post_max_size = ' . ini_get('post_max_size'));
						}
					}

				//--------------------------------------------------
				// Validation on upload

					if ($this->uploaded) {

						$error_html = str_replace('XXX', format_bytes($this->max_size), $error_html);

						foreach ($this->files as $id => $file) {

							if ($file['error'] == 1) {
								$this->form->_field_error_set_html($this->form_field_uid, $error_html, 'ERROR: Exceeds "upload_max_filesize" ' . ini_get('upload_max_filesize'));
								$this->files[$id]['preserve'] = false;
							}

							if ($file['error'] == 2) {
								$this->form->_field_error_set_html($this->form_field_uid, $error_html, 'ERROR: Exceeds "MAX_FILE_SIZE" specified in the html form');
								$this->files[$id]['preserve'] = false;
							}

							if ($file['size'] >= $this->max_size) {
								$this->form->_field_error_set_html($this->form_field_uid, $error_html);
								$this->files[$id]['preserve'] = false;
							}

						}

					}

			}

			public function max_size_get() {

				$size = $this->max_size;

				if ($size == 0) {

					foreach (array('upload_max_filesize', 'post_max_size') as $ini) {

						if ($limit = ini_get($ini)) {

							$limit = parse_bytes($limit);

							if (($limit > 0) && ($size == 0 || $size > $limit)) {
								$size = $limit;
							}

						}

					}

				}

				return $size;

			}

			public function partial_file_error_set($error) {
				$this->partial_file_error_set_html(to_safe_html($error));
			}

			public function partial_file_error_set_html($error_html) {

				if ($this->uploaded) {
					foreach ($this->files as $id => $file) {
						if ($file['error'] == 3) {
							$this->form->_field_error_set_html($this->form_field_uid, $error_html);
							$this->files[$id]['preserve'] = false;
						}
					}
				}

				$this->partial_file_error_set = true;

			}

			public function allowed_file_types_mime_set($error, $types) {
				$this->allowed_file_types_mime_set_html(to_safe_html($error), $types);
			}

			public function allowed_file_types_mime_set_html($error_html, $types) {

				if ($this->uploaded) {
					foreach ($this->files as $id => $file) {
						if (!in_array($file['mime'], $types)) {
							$this->form->_field_error_set_html($this->form_field_uid, str_replace('XXX', $file['mime'], $error_html), 'MIME: ' . $file['mime']);
							$this->files[$id]['preserve'] = false;
						}
					}
				}

				$this->file_accept_mime = $types;

			}

			public function allowed_file_types_ext_set($error, $types) {
				$this->allowed_file_types_ext_set_html(to_safe_html($error), $types);
			}

			public function allowed_file_types_ext_set_html($error_html, $types) {

				if ($this->uploaded) {
					foreach ($this->files as $id => $file) {
						if (!in_array($file['ext'], $types)) {
							$this->form->_field_error_set_html($this->form_field_uid, str_replace('XXX', $file['ext'], $error_html), 'EXT: ' . $file['ext']);
							$this->files[$id]['preserve'] = false;
						}
					}
				}

				$this->file_accept_ext = $types;

			}

			public function empty_file_error_set($error) {
				$this->empty_file_error_set_html(to_safe_html($error));
			}

			public function empty_file_error_set_html($error_html) {

				if ($this->uploaded) {
					foreach ($this->files as $id => $file) {
						if ($file['size'] == 0) {
							$this->form->_field_error_set_html($this->form_field_uid, $error_html);
							$this->files[$id]['preserve'] = false;
						}
					}
				}

				$this->empty_file_error_set = true;

			}

			public function blank_name_error_set($error) {
				$this->blank_name_error_set_html(to_safe_html($error));
			}

			public function blank_name_error_set_html($error_html) {

				if ($this->uploaded) {
					foreach ($this->files as $id => $file) {
						$name = pathinfo($file['name'], PATHINFO_FILENAME); // Exclude extension - Don't want ".jpg" passing PHP "jpg" check, but being seen as a hidden file with no extension by the web server.
						if ($name == '') {
							$this->form->_field_error_set_html($this->form_field_uid, $error_html);
							$this->files[$id]['preserve'] = false;
						}
					}
				}

				$this->blank_name_error_set = true;

			}

			public function long_name_error_set($error, $length = 100) {
				$this->long_name_error_set_html(to_safe_html($error), $length);
			}

			public function long_name_error_set_html($error_html, $length = 100) {

				$error_html = str_replace('XXX', $length, $error_html);

				if ($this->uploaded) {
					foreach ($this->files as $id => $file) {
						if (strlen($file['name']) > $length) {
							$html = str_replace('[FILE_NAME]', $file['name'], $error_html);
							$this->form->_field_error_set_html($this->form_field_uid, $html);
							$this->files[$id]['preserve'] = false;
						}
					}
				}

				$this->long_name_error_set = true;

			}

		//--------------------------------------------------
		// Status

			public function uploaded() {
				return (isset($this->files[$this->file_current]));
			}

			public function upload_next() { // For multi-file uploads
				$this->file_current++;
			}

			public function upload_reset() {
				$this->file_current = 0;
			}

		//--------------------------------------------------
		// Errors

			public function error_file_add($id, $error, $hidden_info = NULL) {

				if (isset($this->files[$id])) {
					$this->files[$id]['preserve'] = false;
				} else {
					exit_with_error('Unknown file id "' . $id . '"');
				}

				$this->error_add($error, $hidden_info);

			}

		//--------------------------------------------------
		// Value

			public function value_get() {
				exit('<p>Do you mean file_path_get?</p>');
			}

			public function value_file_names_get() {
				$file_names = [];
				foreach ($this->files as $file) {
					if ($file['preserve']) {
						$file_names[] = $file['name'];
					}
				}
				return $file_names;
			}

			public function value_hashes_get() {
				$hashes = [];
				foreach ($this->files as $file) {
					if ($file['preserve']) {
						$hashes[] = $file['hash'];
					}
				}
				return $hashes;
			}

			public function value_hidden_get() {
				$hashes = $this->value_hashes_get();
				if (count($hashes) > 0) {
					return implode('-', $hashes);
				} else {
					return NULL;
				}
			}

			public function files_get() {
				$return = [];
				foreach ($this->files as $file) {
					if ($file['error'] == 0) {
						$return[] = array(
								'path' => $file['path'],
								'ext' => $file['ext'],
								'name' => $file['name'],
								'size' => $file['size'],
								'mime' => $file['mime'],
							); // Don't expose preserve/error/hash keys
					}
				}
				return $return;
			}

			public function file_id_get() {
				return $this->file_current;
			}

			public function file_path_get() {
				return $this->_file_info_get('path');
			}

			public function file_ext_get() {
				return $this->_file_info_get('ext');
			}

			public function file_name_get() {
				return $this->_file_info_get('name');
			}

			public function file_size_get() {
				return $this->_file_info_get('size');
			}

			public function file_mime_get() {
				return $this->_file_info_get('mime');
			}

			public function file_save_to($path_dst) {
				$path_src = $this->file_path_get();
				if ($path_src) {

					$folder = dirname($path_dst);
					if (!is_dir($folder)) {
						@mkdir($folder, 0777, true);
					}

					if (is_file($path_dst) && !is_writable($path_dst)) {
						exit_with_error('Cannot save file "' . $this->label_html . '", check destination file permissions.', $path_dst);
					} else if (!is_writable(dirname($path_dst))) {
						exit_with_error('Cannot save file "' . $this->label_html . '", check destination folder permissions.', dirname($path_dst));
					}

					$return = copy($path_src, $path_dst); // Don't unlink/rename, as the same file may have been uploaded multiple times.

					@chmod($path_dst, octdec(config::get('file.default_permission', 666)));

					return $return;

				}
				return false;
			}

			protected function _file_info_get($field) {
				if (isset($this->files[$this->file_current][$field])) {
					return $this->files[$this->file_current][$field];
				} else {
					return NULL;
				}
			}

		//--------------------------------------------------
		// Validation

			public function _post_validation() {

				parent::_post_validation();

				if ($this->max_size == 0) {
					exit('<p>You need to call "max_size_set", on the field "' . $this->label_html . '"</p>');
				}

				if ($this->form_submitted && isset($_SERVER['CONTENT_TYPE']) && $_SERVER['CONTENT_TYPE'] == 'application/x-www-form-urlencoded') { // If not set, assume its correct
					exit('<p>The form needs the attribute: <strong>enctype="multipart/form-data"</strong></p>');
				}

				if ($this->empty_file_error_set == false) { // Provide default
					$this->empty_file_error_set('The uploaded file for "' . strtolower($this->label_html) . '" is empty.');
				}

				if ($this->partial_file_error_set == false) { // Provide default
					$this->partial_file_error_set('The uploaded file for "' . strtolower($this->label_html) . '" was only partially uploaded.');
				}

				if ($this->blank_name_error_set == false) { // Provide default
					$this->blank_name_error_set('The uploaded file for "' . strtolower($this->label_html) . '" does not have a filename.');
				}

				if ($this->long_name_error_set == false) { // Provide default
					$this->long_name_error_set('The uploaded file "[FILE_NAME]", has a filename that is too long (max XXX characters).'); // for "' . strtolower($this->label_html) . '"
				}

			}

		//--------------------------------------------------
		// Attributes

			protected function _input_attributes() {

				$attributes = parent::_input_attributes();

				if (isset($attributes['required']) && count($this->value_hashes_get()) > 0) {
					unset($attributes['required']); // Preserved files though hidden field
				}

				$attributes['type'] = 'file';
				$attributes['name'] .= '[]'; // Treat all uploads as supporting multiple files, we will still just use the first one.

				if ($this->multiple) {
					$attributes['multiple'] = 'multiple';
				}

				$accept = [];
				if ($this->file_accept_mime !== NULL) {
					$accept = $this->file_accept_mime;
				}
				if ($this->file_accept_ext !== NULL) {
					foreach ($this->file_accept_ext as $ext) {
						$accept[] = '.' . $ext;
					}
				}
				if (count($accept) > 0) {
					$attributes['accept'] = implode(',', $accept);
				}

				return $attributes;

			}

		//--------------------------------------------------
		// HTML

			public function html_input() {
				return $this->_html_input();
			}

			public function html_info($indent = 0) {

				$file_names = $this->value_file_names_get();
				if (count($file_names) > 0) {
					if ($this->multiple) {
						$file_names = implode(', ', $file_names);
					} else {
						$file_names = array_shift($file_names);
					}
					$this->info_html = html($file_names) . ($this->info_html ? ' | ' . $this->info_html : '');
				}

				return parent::html_info($indent);

			}

	}

	class form_field_image extends form_field_file {

		//--------------------------------------------------
		// Variables

			protected $min_width_size = 0;
			protected $max_width_size = 0;
			protected $min_height_size = 0;
			protected $max_height_size = 0;

			protected $file_type_error_set = false;

		//--------------------------------------------------
		// Setup

			public function __construct($form, $label, $name = NULL) {

				//--------------------------------------------------
				// Perform the standard field setup

					$this->setup_file($form, $label, $name, 'image');

				//--------------------------------------------------
				// File values

					foreach ($this->files as $id => $file) {

						$dimensions = ($file['path'] == '' ? false : getimagesize($file['path']));
						if ($dimensions !== false) {

							if ($dimensions[2] == IMAGETYPE_JPEG) {
								$image_type = 'jpg';
							} else if ($dimensions[2] == IMAGETYPE_GIF) {
								$image_type = 'gif';
							} else if ($dimensions[2] == IMAGETYPE_PNG) {
								$image_type = 'png';
							} else {
								$image_type = NULL;
							}

							$this->files[$id]['image_width'] = $dimensions[0];
							$this->files[$id]['image_height'] = $dimensions[1];
							$this->files[$id]['image_type'] = $image_type;

						} else {

							$this->files[$id]['image_width'] = NULL;
							$this->files[$id]['image_height'] = NULL;
							$this->files[$id]['image_type'] = NULL;

						}

					}

			}

		//--------------------------------------------------
		// Errors

			public function min_width_set($error, $size) {
				$this->min_width_set_html(to_safe_html($error), $size);
			}

			public function min_width_set_html($error_html, $size) {

				$size = intval($size);

				if ($this->uploaded) {
					foreach ($this->files as $id => $file) {
						if ($file['image_width'] !== NULL && $file['image_width'] < $size) {
							$this->form->_field_error_add_html($this->form_field_uid, str_replace('XXX', $size . 'px', $error_html), $file['image_width'] . 'px');
							$this->files[$id]['preserve'] = false;
						}
					}
				}

				$this->min_width_size = $size;

			}

			public function max_width_set($error, $size) {
				$this->max_width_set_html(to_safe_html($error), $size);
			}

			public function max_width_set_html($error_html, $size) {

				$size = intval($size);

				if ($this->uploaded) {
					foreach ($this->files as $id => $file) {
						if ($file['image_width'] !== NULL && $file['image_width'] > $size) {
							$this->form->_field_error_add_html($this->form_field_uid, str_replace('XXX', $size . 'px', $error_html), $file['image_width'] . 'px');
							$this->files[$id]['preserve'] = false;
						}
					}
				}

				$this->max_width_size = $size;

			}

			public function required_width_set($error, $size) {
				$this->required_width_set_html(to_safe_html($error), $size);
			}

			public function required_width_set_html($error_html, $size) {

				$size = intval($size);

				if ($this->uploaded) {
					foreach ($this->files as $id => $file) {
						if ($file['image_width'] !== NULL && $file['image_width'] != $size) {
							$this->form->_field_error_add_html($this->form_field_uid, str_replace('XXX', $size . 'px', $error_html), $file['image_width'] . 'px');
							$this->files[$id]['preserve'] = false;
						}
					}
				}

				$this->min_width_size = $size;
				$this->max_width_size = $size;

			}

			public function required_width_min_get() {
				return $this->min_width_size;
			}

			public function required_width_max_get() {
				return $this->max_width_size;
			}

			public function required_width_get() {
				return $this->min_width_size;
			}

			public function min_height_set($error, $size) {
				$this->min_height_set_html(to_safe_html($error), $size);
			}

			public function min_height_set_html($error_html, $size) {

				$size = intval($size);

				if ($this->uploaded) {
					foreach ($this->files as $id => $file) {
						if ($file['image_height'] !== NULL && $file['image_height'] < $size) {
							$this->form->_field_error_add_html($this->form_field_uid, str_replace('XXX', $size . 'px', $error_html), $file['image_height'] . 'px');
							$this->files[$id]['preserve'] = false;
						}
					}
				}

				$this->min_height_size = $size;

			}

			public function max_height_set($error, $size) {
				$this->max_height_set_html(to_safe_html($error), $size);
			}

			public function max_height_set_html($error_html, $size) {

				$size = intval($size);

				if ($this->uploaded) {
					foreach ($this->files as $id => $file) {
						if ($file['image_height'] !== NULL && $file['image_height'] > $size) {
							$this->form->_field_error_add_html($this->form_field_uid, str_replace('XXX', $size . 'px', $error_html), $file['image_height'] . 'px');
							$this->files[$id]['preserve'] = false;
						}
					}
				}

				$this->max_height_size = $size;

			}

			public function required_height_set($error, $size) {
				$this->required_height_set_html(to_safe_html($error), $size);
			}

			public function required_height_set_html($error_html, $size) {

				$size = intval($size);

				if ($this->uploaded) {
					foreach ($this->files as $id => $file) {
						if ($file['image_height'] !== NULL && $file['image_height'] != $size) {
							$this->form->_field_error_add_html($this->form_field_uid, str_replace('XXX', $size . 'px', $error_html), $file['image_height'] . 'px');
							$this->files[$id]['preserve'] = false;
						}
					}
				}

				$this->min_height_size = $size;
				$this->max_height_size = $size;

			}

			public function required_height_min_get() {
				return $this->min_height_size;
			}

			public function required_height_max_get() {
				return $this->max_height_size;
			}

			public function required_height_get() {
				return $this->min_height_size;
			}

			public function file_type_error_set($error, $types = NULL) {
				$this->file_type_error_set_html(to_safe_html($error), $types);
			}

			public function file_type_error_set_html($error_html, $types = NULL) {

				//--------------------------------------------------
				// Types

					if ($types === NULL) {
						$types = array('gif', 'jpg', 'png');
					}

				//--------------------------------------------------
				// Validate the mime type

					$mime_types = [];

					if (in_array('gif', $types)) {
						$mime_types[] = 'image/gif';
					}

					if (in_array('jpg', $types)) {
						$mime_types[] = 'image/jpeg';
						$mime_types[] = 'image/pjpeg'; // The wonderful world of IE
					}

					if (in_array('png', $types)) {
						$mime_types[] = 'image/png';
						$mime_types[] = 'image/x-png'; // The wonderful world of IE
					}

					parent::allowed_file_types_mime_set_html($error_html, $mime_types);

				//--------------------------------------------------
				// Could not use getimagesize

					if ($this->uploaded) {
						foreach ($this->files as $id => $file) {

							if ($file['image_type'] == NULL) {

								$this->form->_field_error_set_html($this->form_field_uid, str_replace('XXX', 'invalid image', $error_html), 'ERROR: Failed getimagesize');

							} else {

								if (!in_array($file['image_type'], $types)) {
									$this->form->_field_error_set_html($this->form_field_uid, str_replace('XXX', $file['image_type'], $error_html), 'ERROR: Non valid type (' . implode(', ', $types) . ')');
								}

							}

						}
					}

				//--------------------------------------------------
				// Done

					$this->file_type_error_set = true;

			}

		//--------------------------------------------------
		// Value

			public function image_width_get() {
				return $this->_file_info_get('image_width');
			}

			public function image_height_get() {
				return $this->_file_info_get('image_height');
			}

			public function image_type_get() {
				return $this->_file_info_get('image_type');
			}

		//--------------------------------------------------
		// Validation

			public function _post_validation() {

				parent::_post_validation();

				if ($this->file_type_error_set == false) {
					exit('<p>You need to call "file_type_error_set", on the field "' . $this->label_html . '"</p>');
				}

			}

		//--------------------------------------------------
		// Attributes

			protected function _input_attributes() {
				$attributes = parent::_input_attributes();
				if (!isset($attributes['accept'])) {
					$attributes['accept'] = 'image/*';
				}
				return $attributes;
			}

	}

	class form_field_fields extends form_field {

		//--------------------------------------------------
		// Variables

			protected $value = NULL;
			protected $value_default = NULL;
			protected $value_provided = NULL;

			protected $fields = [];
			protected $placeholders = [];
			protected $format_html = array('separator' => ' ');
			protected $invalid_error_set = false;
			protected $invalid_error_found = false;
			protected $input_order = [];
			protected $input_separator = ' ';
			protected $input_config = [];
			protected $input_described_by = NULL; // Disabled, as these fields use aria-label

		//--------------------------------------------------
		// Setup

			public function __construct($form, $label, $name = NULL) {
				$this->setup_fields($form, $label, $name, 'fields');
			}

			protected function setup_fields($form, $label, $name, $type) {

				//--------------------------------------------------
				// General setup

					$this->setup($form, $label, $name, $type);

				//--------------------------------------------------
				// Value

					$this->value = NULL;

					if ($this->form_submitted || $this->form->saved_values_available()) {

						$hidden_value = $this->form->hidden_value_get('h-' . $this->name);

						if ($hidden_value !== NULL) {

							$this->value_set($hidden_value);

						} else {

							if ($this->form_submitted) {
								$request_value = request($this->name, $this->form->form_method_get());
							} else {
								$request_value = $this->form->saved_value_get($this->name);
							}

							if ($request_value !== NULL) {
								$this->value_set($request_value);
							}

						}

					}

					$this->value_provided = NULL; // Reset after initial value_set (calling again will then be 'provided')... but date/time fields might change (via setup_fields), or NULL will worked out during required_error_set_html (after input_add).

			}

			public function input_first_id_get() {
				return $this->id . '_' . reset($this->input_order);
			}

			public function placeholder_set($placeholder) {
				$placeholder = $this->_value_parse($placeholder);
				if ($placeholder) {
					$this->placeholders_set($placeholder);
				}
			}

			public function placeholders_set($placeholders) {
				$this->placeholders = $placeholders;
			}

		//--------------------------------------------------
		// Format

			public function format_set($format) {
				$this->format_set_html(is_array($format) ? array_map('to_safe_html', $format) : to_safe_html($format));
			}

			public function format_set_html($format_html) {
				if (is_array($format_html)) {
					$this->format_html = array_merge($this->format_html, $format_html);
				} else {
					$this->format_html = $format_html;
				}
			}

			public function format_default_get_html() {

				if (!is_array($this->format_html)) {

					return $this->format_html;

				} else {

					$format_html = [];

					foreach ($this->input_order as $field) {
						if (isset($this->format_html[$field])) {
							$label_html = $this->format_html[$field];
						} else if (!is_array($this->input_config[$field]['options'])) { // Not using a <select> field.
							$label_html = html($this->input_config[$field]['label']);
						} else {
							$label_html = NULL;
						}
						if ($label_html) {
							$format_html[] = '<label for="' . html($this->id) . '_' . html($field) . '">' . $label_html . '</label>';
						}
					}

					$format_html = implode($this->format_html['separator'], $format_html);

					if (isset($this->format_html['suffix'])) {
						$format_html .= $this->format_html['suffix'];
					}

					return $format_html;

				}

			}

		//--------------------------------------------------
		// Inputs

			public function input_add($field, $config) {

				//--------------------------------------------------
				// Base array of fields

					if (in_array($field, $this->fields)) {
						exit_with_error('There is already a field "' . $field . '"');
					}

					$this->fields[] = $field;

				//--------------------------------------------------
				// Add format

					if (isset($config['format'])) {
						$config['format_html'] = html($config['format']);
					}
					if (isset($config['format_html'])) {
						$this->format_html[$field] = $config['format_html'];
					}

				//--------------------------------------------------
				// Add to order

					if (!in_array($field, $this->input_order)) {
						$this->input_order[] = $field;
					}

				//--------------------------------------------------
				// Add config

					$this->input_config[$field] = array_merge(array(
							'size' => NULL,
							'pad_length' => 0,
							'pad_char' => '0',
							'label' => '',
							'label_aria' => '',
							'input_required' => true,
							'options' => NULL,
						), $config);

			}

			public function input_add_complete() { // Call after ->input_add(), after all of the input fields have been added (all need to be provided)

				if ($this->value_provided === NULL) {
					$this->value_provided = true;
					foreach ($this->fields as $field) {
						$value = (isset($this->value[$field]) ? $this->value[$field] : NULL);
						if ($value === NULL || (is_array($this->input_config[$field]['options']) && $value == '')) {
							$this->value_provided = false;
						}
					}
				}

			}

			public function input_order_set($order) {
				foreach ($order as $field) {
					if (!in_array($field, $this->fields)) {
						exit_with_error('Invalid field "' . $field . '" when setting input order');
					}
				}
				$this->input_order = $order; // An array
			}

			public function input_separator_set($separator) {
				$this->input_separator = $separator;
			}

			public function input_config_set($field, $config, $value = NULL) {
				if (in_array($field, $this->fields)) {
					if (is_array($config)) {
						$this->input_config[$field] = array_merge($this->input_config[$field], $config);
					} else {
						$this->input_config[$field][$config] = $value;
					}
				} else {
					exit_with_error('Invalid field "' . $field . '" when setting input config');
				}
			}

			public function input_options_value_set($field, $options, $label = '') { // Only use the values (ignores the keys)
				$this->input_options_text_set($field, array_combine($options, $options), $label);
			}

			public function input_options_text_set($field, $options, $label = '') { // Uses the array keys for the value, and array values for display text
				if ($this->invalid_error_set) {
					exit_with_error('Cannot call input_options_text_set() after invalid_error_set()');
				}
				$this->input_config_set($field, array('options' => $options, 'label' => $label));
			}

		//--------------------------------------------------
		// Value

			public function value_set($value) {
				$this->value = $this->_value_parse($value);
				$this->value_provided = true;
			}

			public function value_default_set($default) {

					// Typically used by date/time fields,
					// and only when the form is submitted.
					//
					// If you want to show a default, then
					// do the same as other fields:
					//
					// if ($form->initial()) {
					// 	$field->value_set('XXX');
					// }

				$this->value_default = $default;

			}

			public function value_get($field = NULL) {
				if ($field !== NULL) {
					if (!in_array($field, $this->fields)) {
						exit_with_error('Invalid field specified "' . $field . '"');
					}
					if (isset($this->value[$field])) {
						return $this->value[$field];
					} else {
						return NULL;
					}
				} else if ($this->value_provided) {
					$return = [];
					foreach ($this->fields as $field) {
						$return[$field] = (isset($this->value[$field]) ? $this->value[$field] : NULL);
					}
					return $return;
				} else {
					return NULL; // Not value_default
				}
			}

			protected function _value_print_get() {
				if ($this->value === NULL && !$this->value_provided) {
					if ($this->db_field_name !== NULL) {
						$db_value = $this->db_field_value_get();
					} else {
						$db_value = NULL;
					}
					return $this->_value_parse($db_value);
				}
				return $this->value;
			}

			public function value_hidden_get() {
				if ($this->print_hidden) {
					return $this->_value_string($this->_value_print_get());
				} else {
					return NULL;
				}
			}

			protected function _value_string($value) {
				return json_encode($value);
			}

			protected function _value_parse($value) {
				if (is_array($value)) {
					return $value;
				} else {
					return json_decode(strval($value), true); // Array
				}
			}

		//--------------------------------------------------
		// Errors

			public function required_error_set($error) {
				$this->required_error_set_html(to_safe_html($error));
			}

			public function required_error_set_html($error_html) {

				if ($this->form_submitted) {

					$this->input_add_complete(); // Should be called manually, but historically value_provided used to be checked here.

					if (!$this->value_provided) {
						$this->form->_field_error_set_html($this->form_field_uid, $error_html);
					}

				}

				$this->required = ($error_html !== NULL);

			}

			public function invalid_error_set($error) {
				$this->invalid_error_set_html(to_safe_html($error));
			}

			public function invalid_error_set_html($error_html) {

				if ($this->form_submitted && $this->value_provided) {

					$valid = true;

					foreach ($this->fields as $field) {
						$value = (isset($this->value[$field]) ? $this->value[$field] : NULL);
						if (is_array($this->input_config[$field]['options']) && $value != '' && !isset($this->input_config[$field]['options'][$value])) {
							$valid = false;
						}
					}

					if (!$valid) {

						$this->form->_field_error_set_html($this->form_field_uid, $error_html);

						$this->invalid_error_found = true;

					}

				}

				$this->invalid_error_set = true;

			}

		//--------------------------------------------------
		// Validation

			public function _post_validation() {

				parent::_post_validation();

				if ($this->invalid_error_set == false) {
					$options_exist = false;
					foreach ($this->fields as $field) {
						if (is_array($this->input_config[$field]['options'])) {
							$options_exist = true;
							break;
						}
					}
					if ($options_exist) {
						exit('<p>You need to call "invalid_error_set", on the field "' . $this->label_html . '"</p>');
					}
				}

			}

		//--------------------------------------------------
		// HTML

			public function html_label($field = NULL, $label_html = NULL) {

				//--------------------------------------------------
				// Check the field

					if ($field === NULL) {
						$field = reset($this->input_order);
					}

					if (!in_array($field, $this->fields)) {
						return 'The label field is invalid (' . implode(' / ', $this->fields) . ')';
					}

				//--------------------------------------------------
				// Required mark position

					$required_mark_position = $this->required_mark_position;
					if ($required_mark_position === NULL) {
						$required_mark_position = $this->form->required_mark_position_get();
					}

				//--------------------------------------------------
				// If this field is required, try to get a required
				// mark of some form

					if ($this->required || $this->required_mark_html !== NULL) {
						if ($this->required_mark_html !== NULL && $this->required_mark_html !== true) {
							$required_mark_html = $this->required_mark_html;
						} else {
							$required_mark_html = $this->form->required_mark_get_html($required_mark_position);
						}
					} else {
						$required_mark_html = NULL;
					}

				//--------------------------------------------------
				// Return the HTML for the label

					if ($label_html === NULL) {
						$label_html = $this->label_html;
					}

					if ($label_html != '') {

						$for = $this->id;
						if (!isset($this->input_single) || !$this->input_single) {
							$for .= '_' . $field;
						}

						return $this->label_prefix_html . '<label for="' . html($for) . '"' . ($this->label_class === NULL ? '' : ' class="' . html($this->label_class) . '"') . '>' . ($required_mark_position == 'left' && $required_mark_html !== NULL ? $required_mark_html : '') . $label_html . ($required_mark_position == 'right' && $required_mark_html !== NULL ? $required_mark_html : '') . '</label>' . $this->label_suffix_html;

					} else {

						return '';

					}

			}

			public function html_input_field($field, $input_value = NULL) {

				if (!in_array($field, $this->fields)) {
					return 'The input field is invalid (' . implode(' / ', $this->fields) . ')';
				}

				$input_config = $this->input_config[$field];

				if (!$input_value) {
					$input_value = $this->_value_print_get(); // html_input() will pass in, but other code may not
				}

				$attributes = array(
						'name' => $this->name . '[' . $field . ']',
						'id' => $this->id . '_' . $field,
					);

				if ($field != reset($this->fields)) {
					$attributes['autofocus'] = NULL;
				}

				if (isset($this->placeholders[$field])) {
					$attributes['placeholder'] = $this->placeholders[$field];
				}

				if ($input_config['label_aria']) {
					if ($this->label_aria === '') {
						$attributes['aria-label'] = $input_config['label_aria'];
					} else if ($this->label_aria !== NULL) {
						$attributes['aria-label'] = $this->label_aria . ' (' . $input_config['label_aria'] . ')';
					} else if ($this->label_html) {
						$attributes['aria-label'] = html_decode($this->label_html) . ' (' . $input_config['label_aria'] . ')';
					}
				}

				if ($input_config['input_required'] === false) { // So the Minute/Second time fields can be left blank, to be XX:00:00.
					$attributes['required'] = NULL;
				}

				if ($this->type == 'date' && $this->autocomplete === 'bday') {
					$field_name = array('D' => 'day', 'M' => 'month', 'Y' => 'year');
					$attributes['autocomplete'] = 'bday-' . $field_name[$field];
				}

				if (is_array($input_config['options'])) {

					$html = html_tag('select', array_merge($this->_input_attributes(), $attributes));

					if ($input_config['label'] !== NULL) {
						$html .= '
									<option value="">' . html($input_config['label']) . '</option>';
					}

					foreach ($input_config['options'] as $option_value => $option_text) {

						$selected = ($input_value !== NULL && $input_value[$field] !== NULL && strval($input_value[$field]) === strval($option_value)); // Can't use intval as some fields use text keys, also difference between '' and '0'.

						if ($input_config['pad_length'] > 0) {
							$option_text = str_pad($option_text, $input_config['pad_length'], $input_config['pad_char'], STR_PAD_LEFT);
						}

						$html .= '
									<option value="' . html($option_value) . '"' . ($selected ? ' selected="selected"' : '') . '>' . html($option_text) . '</option>';

					}

					return $html . '
								</select>';

				} else {

					$value = (isset($input_value[$field]) ? $input_value[$field] : NULL);

					if ($input_config['pad_length'] > 0) {
						if ($value !== NULL && $value !== '') {
							$value = str_pad($value, $input_config['pad_length'], $input_config['pad_char'], STR_PAD_LEFT);
						}
					} else if ($value == 0) {
						$value = '';
					}

					$attributes['value'] = strval($value); // Ensure the attribute is still present for NULL values - e.g. for JS query input[value!=""]
					$attributes['type'] = 'text';

					if ($input_config['size']) {
						$attributes['maxlength'] = $input_config['size'];
						$attributes['size'] = $input_config['size'];
					}

					return $this->_html_input($attributes);

				}

			}

			public function html_input() {
				$input_value = $this->_value_print_get();
				$input_html = [];
				foreach ($this->input_order as $html) {
					if (in_array($html, $this->fields)) {
						$input_html[] = $this->html_input_field($html, $input_value);
					} else {
						$input_html[] = $html;
					}
				}
				return "\n\t\t\t\t\t\t\t\t\t" . implode($this->input_separator, $input_html) . "\n\t\t\t\t\t\t\t\t";
			}

	}

	class form_field_date extends form_field_fields {

		//--------------------------------------------------
		// Variables

			protected $input_day = NULL;
			protected $input_single = false;
			protected $input_partial_allowed = false;

		//--------------------------------------------------
		// Setup

			public function __construct($form, $label, $name = NULL) {

				//--------------------------------------------------
				// Fields setup

					$this->fields = array('D', 'M', 'Y');
					$this->format_html = array_merge(array('separator' => '/', 'D' => 'DD', 'M' => 'MM', 'Y' => 'YYYY'), config::get('form.date_format_html', []));
					$this->value_default = '0000-00-00';
					$this->input_separator = "\n\t\t\t\t\t\t\t\t\t";
					$this->input_config = array(
							'D' => array(
									'size' => 2,
									'pad_length' => 0,
									'pad_char' => '0',
									'label' => '',
									'label_aria' => 'Day',
									'input_required' => true,
									'options' => NULL,
								),
							'M' => array(
									'size' => 2,
									'pad_length' => 0,
									'pad_char' => '0',
									'label' => '',
									'label_aria' => 'Month',
									'input_required' => true,
									'options' => NULL,
								),
							'Y' => array(
									'size' => 4,
									'pad_length' => 0,
									'pad_char' => '0',
									'label' => '',
									'label_aria' => 'Year',
									'input_required' => true,
									'options' => NULL,
								));

					$this->setup_fields($form, $label, $name, 'date');

					$this->input_single = config::get('form.date_input_single', false); // Use the standard 3 input fields by default, as most browsers still cannot do HTML5 type="date" fields.
					$this->input_order_set(config::get('form.date_input_order', array('D', 'M', 'Y')));

				//--------------------------------------------------
				// Guess correct year if only 2 digits provided

					if ($this->value !== NULL && $this->value['Y'] > 0 && $this->value['Y'] < 100) {
						$this->value['Y'] = DateTime::createFromFormat('y', $this->value['Y'])->format('Y');
					}

				//--------------------------------------------------
				// Value provided

					$this->value_provided = (is_array($this->value) && ($this->value['D'] != 0 || $this->value['M'] != 0 || $this->value['Y'] != 0));

			}

			public function input_order_set($order) {
				parent::input_order_set($order);
				$this->input_day = in_array('D', $this->input_order);
			}

			public function input_options_text_set($field, $options, $label = '') {
				if ($field == 'M' && !is_array($options)) {
					$months = [];
					for ($k = 1; $k <= 12; $k++) {
						$months[$k] = date($options, mktime(0, 0, 0, $k, 1)); // Must specify day, as on the 31st this will push other month 2 is pushed to March
					}
					$options = $months;
				}
				parent::input_options_text_set($field, $options, $label);
			}

			public function input_partial_allowed_set($input_partial_allowed) {
				$this->input_partial_allowed = $input_partial_allowed;
			}

		//--------------------------------------------------
		// Format

			public function format_default_get_html() {

				if ($this->input_single === true && is_array($this->format_html)) {

					return '';

				} else {

					return parent::format_default_get_html();

				}

			}

		//--------------------------------------------------
		// Errors

			public function invalid_error_set_html($error_html) {

				if ($this->form_submitted && $this->value_provided) {

					$valid = true;

					$time_stamp_value = $this->value_time_stamp_get(); // Check upper bound to time-stamp, 2037 on 32bit systems

					$partial_value = $this->value;
					if (!$this->input_day) {
						$partial_value['D'] = 1;
					}
					if ($this->input_partial_allowed) {
						if ($partial_value['D'] == 0) $partial_value['D'] = 1;
						if ($partial_value['M'] == 0) $partial_value['M'] = 1;
						if ($partial_value['Y'] == 0) $partial_value['Y'] = 2000;
					}

					if (!checkdate($partial_value['M'], $partial_value['D'], $partial_value['Y']) || $time_stamp_value === false) {
						$valid = false;
					}

					foreach ($this->fields as $field) {
						if (is_array($this->input_config[$field]['options']) && !isset($this->input_config[$field]['options'][$this->value[$field]])) {
							$valid = false;
						}
					}

					if (!$valid) {

						$this->form->_field_error_set_html($this->form_field_uid, $error_html);

						$this->invalid_error_found = true;

					}

				}

				$this->invalid_error_set = true;

			}

			public function min_date_set($error, $date) {
				$this->min_date_set_html(to_safe_html($error), $date);
			}

			public function min_date_set_html($error_html, $date) {

				if (!$this->invalid_error_set) {
					exit_with_error('Call invalid_error_set() before min_date_set()');
				}

				if ($this->form_submitted && $this->value_provided && $this->invalid_error_found == false) {

					$value = $this->value_time_stamp_get();

					if (!is_int($date)) {
						$date = strtotime($date);
					}

					if ($value !== false && $value < $date) {
						$this->form->_field_error_set_html($this->form_field_uid, $error_html);
					}

				}

			}

			public function max_date_set($error, $date) {
				$this->max_date_set_html(to_safe_html($error), $date);
			}

			public function max_date_set_html($error_html, $date) {

				if (!$this->invalid_error_set) {
					exit_with_error('Call invalid_error_set() before max_date_set()');
				}

				if ($this->form_submitted && $this->value_provided && $this->invalid_error_found == false) {

					$value = $this->value_time_stamp_get();

					if (!is_int($date)) {
						$date = strtotime($date);
					}

					if ($value !== false && $value > $date) {
						$this->form->_field_error_set_html($this->form_field_uid, $error_html);
					}

				}

			}

		//--------------------------------------------------
		// Value

			public function value_set($value, $month = NULL, $year = NULL) {
				$this->value = $this->_value_parse($value, $month, $year);
				$this->value_provided = ($this->value !== NULL);
			}

			public function value_get($field = NULL) {
				if ($field !== NULL) {
					if (!in_array($field, $this->fields)) {
						exit_with_error('Invalid field specified "' . $field . '"');
					}
					if ($this->value_provided) {
						return $this->value[$field];
					} else {
						return NULL;
					}
				} else if ($this->value_provided) {
					return $this->_value_string($this->value);
				} else {
					return NULL;
				}
			}

			public function value_date_get() {
				$value = $this->value_get();
				if ($value === NULL) {
					$value = $this->value_default;
				}
				return $value;
			}

			public function value_timestamp_get() {
				return new timestamp($this->value_date_get(), 'db');
			}

			public function value_time_stamp_get() { // Legacy name... but you should look at the timestamp helper anyway :-)
				if ($this->value === NULL || $this->value['M'] == 0 && $this->value['D'] == 0 && $this->value['Y'] == 0) {
					$timestamp = false;
				} else {
					$timestamp = mktime(0, 0, 0, $this->value['M'], ($this->input_day ? $this->value['D'] : 1), $this->value['Y']);
				}
				return $timestamp;
			}

			protected function _value_string($value) {
				if ($value !== NULL) {
					return str_pad(intval($value['Y']), 4, '0', STR_PAD_LEFT) . '-' . str_pad(intval($value['M']), 2, '0', STR_PAD_LEFT) . '-' . str_pad(intval($this->input_day ? $value['D'] : 1), 2, '0', STR_PAD_LEFT);
				} else {
					return NULL;
				}
			}

			protected function _value_parse($value, $month = NULL, $year = NULL) {

				if ($month === NULL && $year === NULL) {

					if (is_array($value)) {
						$return = [];
						foreach ($this->fields as $field) {
							$return[$field] = (isset($value[$field]) ? intval($value[$field]) : 0);
						}
						return $return;
					}

					if (!is_numeric($value)) {
						if ($value == '0000-00-00' || $value == '0000-00-00 00:00:00') {

							$value = NULL;

						} else if (preg_match('/^([0-9]{4})-([0-9]{2})-([0-9]{2})/', strval($value), $matches)) { // Should also match "2001-01-00"

							return array(
									'D' => intval($matches[3]),
									'M' => intval($matches[2]),
									'Y' => intval($matches[1]),
								);

						} else {

							$value = strtotime(strval($value));
							if ($value == 943920000) { // "1999-11-30 00:00:00", same as the database "0000-00-00 00:00:00"
								$value = NULL;
							}

						}
					}

					if (is_numeric($value)) {

						return array(
								'D' => intval(date('j', $value)),
								'M' => intval(date('n', $value)),
								'Y' => intval(date('Y', $value)), // Don't render year as "0013"
							);

					}

				} else {

					return array(
							'D' => intval($value),
							'M' => intval($month),
							'Y' => intval($year),
						);

				}

				return NULL;

			}

		//--------------------------------------------------
		// HTML

			public function html_input() {
				if ($this->input_single === true) {

					$value = $this->_value_print_get();
					if ($value) {
						$value = $this->_value_string($value);
					} else {
						$value = '';
					}

// Use min/max attributes, like in `form_field_datetime`?

					return $this->_html_input(array('value' => $value, 'type' => 'date'));

				} else {

					return parent::html_input();

				}
			}

	}

	class form_field_time extends form_field_fields {

		//--------------------------------------------------
		// Setup

			public function __construct($form, $label, $name = NULL) {

				//--------------------------------------------------
				// Fields setup

					$this->fields = array('H', 'I', 'S');
					$this->format_html = array_merge(array('separator' => ':', 'H' => 'HH', 'I' => 'MM', 'S' => 'SS'), config::get('form.time_format_html', []));
					$this->value_default = '00:00:00';
					$this->input_separator = "\n\t\t\t\t\t\t\t\t\t";
					$this->input_config = array(
							'H' => array(
									'size' => 2,
									'pad_length' => 2,
									'pad_char' => '0',
									'label' => '',
									'label_aria' => 'Hour',
									'input_required' => true,
									'options' => NULL,
								),
							'I' => array(
									'size' => 2,
									'pad_length' => 2,
									'pad_char' => '0',
									'label' => '',
									'label_aria' => 'Minute',
									'input_required' => false,
									'options' => NULL,
								),
							'S' => array(
									'size' => 2,
									'pad_length' => 2,
									'pad_char' => '0',
									'label' => '',
									'label_aria' => 'Second',
									'input_required' => false,
									'options' => NULL,
								));

					$this->setup_fields($form, $label, $name, 'time');

					$this->input_order_set(config::get('form.time_input_order', array('H', 'I'))); // Could also be array('H', 'I', 'S')

				//--------------------------------------------------
				// Value provided

					$this->value_provided = false;

					if (is_array($this->value)) {
						foreach ($this->value as $field => $value) {
							if ($value !== NULL && $value !== '') {
								if ($field !== 'H' && $this->value['H'] === '' && $value == 0) {
									continue; // Special case, if the 'hours' is *blank* (0 is a value for midnight), then the 'minutes' or 'seconds' could be 0, but that can be treated as non-values (e.g. user has set the hours drop down to blank, and left minutes at '00').
								}
								$this->value_provided = true; // Look for one non-blank value (i.e. treat '0' as a value); also the 'seconds' field probably does not exist.
								break;
							}
						}
					}

			}

		//--------------------------------------------------
		// Errors

			public function invalid_error_set_html($error_html) {

				if ($this->form_submitted && $this->value_provided) {

					$valid = true;

					$int_values = array_map('intval', $this->value);

					if ($int_values['H'] < 0 || $int_values['H'] > 23) $valid = false;
					if ($int_values['I'] < 0 || $int_values['I'] > 59) $valid = false;
					if ($int_values['S'] < 0 || $int_values['S'] > 59) $valid = false;

					foreach ($this->fields as $field) {
						$value = $this->value[$field];
						if ($value == '') {
							$value = 0; // Treat label as 0, same as when its not required.
						}
						if (is_array($this->input_config[$field]['options']) && !isset($this->input_config[$field]['options'][$value])) {
							$valid = false;
						}
					}

					if (!$valid) {

						$this->form->_field_error_set_html($this->form_field_uid, $error_html);

						$this->invalid_error_found = true; // Bypass min/max style validation

					}

				}

				$this->invalid_error_set = true;

			}

			public function min_time_set($error, $time) {
				$this->min_time_set_html(to_safe_html($error), $time);
			}

			public function min_time_set_html($error_html, $time) {

				if (!$this->invalid_error_set) {
					exit_with_error('Call invalid_error_set() before min_time_set()');
				}

				if ($this->form_submitted && $this->value_provided && $this->invalid_error_found == false) {

					$value = strtotime($this->value_get());

					if (!is_int($time)) {
						$time = strtotime($time);
					}

					if ($value !== false && $value < $time) {
						$this->form->_field_error_set_html($this->form_field_uid, $error_html);
					}

				}

			}

			public function max_time_set($error, $time) {
				$this->max_time_set_html(to_safe_html($error), $time);
			}

			public function max_time_set_html($error_html, $time) {

				if (!$this->invalid_error_set) {
					exit_with_error('Call invalid_error_set() before max_time_set()');
				}

				if ($this->form_submitted && $this->value_provided && $this->invalid_error_found == false) {

					$value = strtotime($this->value_get());

					if (!is_int($time)) {
						$time = strtotime($time);
					}

					if ($value !== false && $value > $time) {
						$this->form->_field_error_set_html($this->form_field_uid, $error_html);
					}

				}

			}

		//--------------------------------------------------
		// Value

			public function value_set($value, $minute = NULL, $second = NULL) {
				$this->value = $this->_value_parse($value, $minute, $second);
				$this->value_provided = true; // If you are providing "00:00:00", then this is considered a value... if you want 00:00:00 to cause the field to be left blank, then don't call this function
			}

			public function value_get($field = NULL) {
				if ($field !== NULL) {
					if (!in_array($field, $this->fields)) {
						exit_with_error('Invalid field specified "' . $field . '"');
					}
					if ($this->value_provided) {
						return intval($this->value[$field]); // Time field (unlike date) does not intval() submitted data, so empty value can be different to 0.
					} else {
						return NULL;
					}
				} else if ($this->value_provided) {
					return $this->_value_string($this->value);
				} else {
					return NULL;
				}
			}

			public function value_time_get() {
				$value = $this->value_get();
				if ($value === NULL) {
					$value = $this->value_default;
				}
				return $value;
			}

			protected function _value_string($value) {
				return str_pad(intval($value['H']), 2, '0', STR_PAD_LEFT) . ':' . str_pad(intval($value['I']), 2, '0', STR_PAD_LEFT) . ':' . str_pad(intval($value['S']), 2, '0', STR_PAD_LEFT);
			}

			protected function _value_parse($value, $minute = NULL, $second = NULL) {

				if ($minute === NULL && $second === NULL) {

					if ($value instanceof timestamp) {
						$value = $value->format('H:i:s');
					} else if (is_array($value)) {
						$return = [];
						foreach ($this->fields as $field) {
							$return[$field] = (isset($value[$field]) ? $value[$field] : '');
						}
						return $return;
					}

					if (preg_match('/^([0-9]{1,2}):([0-9]{1,2})(:([0-9]{1,2}))?$/', strval($value), $matches)) {
						return array(
								'H' => intval($matches[1]),
								'I' => intval($matches[2]),
								'S' => intval(isset($matches[4]) ? $matches[4] : 0),
							);
					}

				} else {

					return array(
							'H' => intval($value),
							'I' => intval($minute),
							'S' => intval($second),
						);

				}

				return NULL;

			}

	}

	class form_field_datetime extends form_field_text {

		//--------------------------------------------------
		// Variables

			protected $step_value = 1; // Can use useful to set to 60 or 3600 (so seconds or minutes are not collected).

			protected $value_provided = false;
			protected $value_timestamp = NULL;

			protected $min_timestamp = NULL;
			protected $max_timestamp = NULL;

			protected $invalid_error_set = false;
			protected $invalid_error_found = false;

		//--------------------------------------------------
		// Setup

			public function __construct($form, $label, $name = NULL) {

				//--------------------------------------------------
				// Perform the standard field setup

					$this->setup_text($form, $label, $name, 'datetime');

				//--------------------------------------------------
				// Clean input value

					if ($this->form_submitted) {
						$this->value_set($this->value);
					}

				//--------------------------------------------------
				// Additional field configuration

					$this->max_length = -1; // Bypass the _post_validation on the text field (not used)
					$this->input_type = 'datetime-local';

			}

		//--------------------------------------------------
		// Errors

			public function required_error_set($error) {
				$this->required_error_set_html(to_safe_html($error));
			}

			public function required_error_set_html($error_html) {

				if ($this->form_submitted && !$this->value_provided) {
					$this->form->_field_error_set_html($this->form_field_uid, $error_html);
				}

				$this->required = ($error_html !== NULL);

			}

			public function invalid_error_set($error) {
				$this->invalid_error_set_html(to_safe_html($error));
			}

			public function invalid_error_set_html($error_html) {

				if ($this->form_submitted && $this->value_provided) {

					if ($this->value_timestamp === NULL) {
						$this->form->_field_error_set_html($this->form_field_uid, $error_html);
						$this->invalid_error_found = true;
					}

				}

				$this->invalid_error_set = true;

			}

			public function min_timestamp_set($error, $timestamp) {
				$this->min_timestamp_set_html(to_safe_html($error), $timestamp);
			}

			public function min_timestamp_set_html($error_html, $timestamp) {

				if (!$this->invalid_error_set) {
					exit_with_error('Call invalid_error_set() before min_timestamp_set()');
				}

				if (!($timestamp instanceof timestamp)) {
					$timestamp = new timestamp($timestamp);
				}
				$this->min_timestamp = $timestamp;

				if ($this->form_submitted && $this->value_provided && $this->invalid_error_found == false) {

					if ($this->value_timestamp !== NULL && $this->value_timestamp < $this->min_timestamp) {
						$this->form->_field_error_set_html($this->form_field_uid, $error_html);
					}

				}

			}

			public function max_timestamp_set($error, $timestamp) {
				$this->max_timestamp_set_html(to_safe_html($error), $timestamp);
			}

			public function max_timestamp_set_html($error_html, $timestamp) {

				if (!$this->invalid_error_set) {
					exit_with_error('Call invalid_error_set() before max_timestamp_set()');
				}

				if (!($timestamp instanceof timestamp)) {
					$timestamp = new timestamp($timestamp);
				}
				$this->max_timestamp = $timestamp;

				if ($this->form_submitted && $this->value_provided && $this->invalid_error_found == false) {

					if ($this->value_timestamp !== NULL && $this->value_timestamp > $this->max_timestamp) {
						$this->form->_field_error_set_html($this->form_field_uid, $error_html);
					}

				}

			}

			public function step_value_set($error, $step = 1) {
				$this->step_value_set_html(to_safe_html($error), $step);
			}

			public function step_value_set_html($error_html, $step = 1) {

				if ($this->form_submitted && $this->value !== '') {

						// Not written yet, just copied from form_field_number

					// $value = $this->value_clean;
					//
					// if ($this->min_value !== NULL) {
					// 	$value += $this->min_value; // HTML step starts at the min value
					// }
					//
					// if (abs((round($value / $step) * $step) - $value) > 0.00001) { // ref 'epsilon' on https://php.net/manual/en/language.types.float.php
					// 	$this->form->_field_error_set_html($this->form_field_uid, str_replace('XXX', $step, $error_html));
					// }

				}

				$this->step_value = $step;

			}

		//--------------------------------------------------
		// Value

			public function value_set($value) {

				$this->value = $value;
				$this->value_provided = ($value != '');
				$this->value_timestamp = NULL;

				if ($this->value_provided) {
					$timestamp = new timestamp($value);
					if (!$timestamp->null()) {
						$this->value_timestamp = $timestamp;
					}
				}

			}

			public function value_get() {
				return $this->value_timestamp;
			}

			protected function _value_print_get() {
				if ($this->value !== '') { // The user submitted blank, let them keep it.
					if ($this->value_timestamp !== NULL) {
						$timestamp = $this->value_timestamp;
					} else if ($this->db_field_name !== NULL) {
						$timestamp = new timestamp($this->db_field_value_get(), 'db');
					} else {
						$timestamp = new timestamp('0000-00-00 00:00:00', 'db');
					}
					if (!$timestamp->null()) {
						return $timestamp->format('Y-m-d\TH:i:s');
					}
				}
				return $this->value; // The browser might not support this field type, so send back what they sent to us (so they can edit invalid values).
			}

		//--------------------------------------------------
		// Validation

			public function _post_validation() {

				parent::_post_validation();

				if ($this->invalid_error_set == false) {
					exit('<p>You need to call "invalid_error_set", on the field "' . $this->label_html . '"</p>');
				}

			}

		//--------------------------------------------------
		// Attributes

			protected function _input_attributes() {

				$attributes = parent::_input_attributes();

				$attributes['step'] = $this->step_value; // Google Chrome 51 will complain if a min/max value is set, and the seconds are different from them (no min/max, then seconds cannot be set).

				if ($this->min_timestamp !== NULL) {
					$attributes['min'] = $this->min_timestamp->format('Y-m-d\TH:i:s');
				}

				if ($this->max_timestamp !== NULL) {
					$attributes['max'] = $this->max_timestamp->format('Y-m-d\TH:i:s');
				}

				return $attributes;

			}

	}

	class form_field_html extends form_field {

		//--------------------------------------------------
		// Variables

			protected $value_html = '';

		//--------------------------------------------------
		// Setup

			public function __construct($form, $label = 'html', $name = NULL) {
				$this->setup_html($form, $label, $name, 'html');
			}

			protected function setup_html($form, $label, $name, $type) {

				//--------------------------------------------------
				// Perform the standard field setup

					$this->setup($form, $label, $name, $type);

				//--------------------------------------------------
				// Value

					$this->value_html = '';

			}

		//--------------------------------------------------
		// Value

			public function value_set($value) {
				$this->value_html = to_safe_html($value);
			}

			public function value_set_html($html) {
				$this->value_html = $html;
			}

		//--------------------------------------------------
		// HTML

			public function html() {
				return $this->value_html;
			}

	}

	class form_field_info extends form_field {

		//--------------------------------------------------
		// Variables

			protected $value = NULL;
			protected $value_html = NULL;
			protected $label_custom_html = NULL;

		//--------------------------------------------------
		// Setup

			public function __construct($form, $label = '', $name = NULL) {
				$this->setup_info($form, $label, $name, 'info');
			}

			protected function setup_info($form, $label, $name, $type) {

				//--------------------------------------------------
				// Perform the standard field setup

					$this->setup($form, $label, $name, $type);

				//--------------------------------------------------
				// Additional field configuration

					$this->readonly = true; // Don't update if linked to a db field

			}

		//--------------------------------------------------
		// Value

			public function value_set($value) {
				if ($value instanceof html_template || $value instanceof html_safe_value) {
					$this->value_html = $value->html();
					$this->value = html_decode($this->value_html);
				} else {
					$this->value = $value;
					$this->value_html = text_to_html($value);
				}
			}

			public function value_set_html($html) {
				$this->value = html_decode($html);
				$this->value_html = $html;
			}

			public function value_set_link($url, $text) {
				if ($url) {
					$this->value_set_html('<a href="' . html($url) . '">' . html($text) . '</a>');
				} else {
					$this->value_set($text);
				}
			}

			public function value_get() {
				return $this->value;
			}

			public function value_get_html() {
				return $this->value_html;
			}

		//--------------------------------------------------
		// Label

			public function label_set($label) {
				$this->label_custom_html = to_safe_html($label);
			}

			public function label_set_html($html) {
				$this->label_custom_html = $html;
			}

		//--------------------------------------------------
		// HTML

			public function html_input() {
				if ($this->value_html !== NULL) {
					return $this->value_html;
				} else if ($this->db_field_name !== NULL) {
					return text_to_html($this->db_field_value_get());
				} else {
					return '';
				}
			}

			public function html_label($label_html = NULL) {
				if ($label_html === NULL) {
					if ($this->label_custom_html !== NULL) {
						$label_html = $this->label_custom_html . $this->label_suffix_html;
					} else {
						$label_html = parent::html_label();
						$label_html = preg_replace('/<label[^>]+>(.*)<\/label>/', '$1', $label_html); // Ugly, but better than duplication
					}
				}
				return $label_html;
			}

	}

?>