<?php
/** ---------------------------------------------------------------------
 * includes/plugins/IDNumbering/MultipartIDNumber.php : plugin to generate id numbers
 * ----------------------------------------------------------------------
 * CollectiveAccess
 * Open-source collections management software
 * ----------------------------------------------------------------------
 *
 * Software by Whirl-i-Gig (http://www.whirl-i-gig.com)
 * Copyright 2007-2025 Whirl-i-Gig
 *
 * For more information visit http://www.CollectiveAccess.org
 *
 * This program is free software; you may redistribute it and/or modify it under
 * the terms of the provided license as published by Whirl-i-Gig
 *
 * CollectiveAccess is distributed in the hope that it will be useful, but
 * WITHOUT ANY WARRANTIES whatsoever, including any implied warranty of 
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  
 *
 * This source code is free and modifiable under the terms of 
 * GNU General Public License. (http://www.gnu.org/copyleft/gpl.html). See
 * the "license.txt" file for details, or visit the CollectiveAccess web site at
 * http://www.CollectiveAccess.org
 *
 * @package CollectiveAccess
 * @subpackage IDNumbering
 * @license http://www.gnu.org/copyleft/gpl.html GNU Public License version 3
 *
 * ----------------------------------------------------------------------
 */
require_once(__CA_LIB_DIR__."/IDNumbering/IDNumber.php");
require_once(__CA_APP_DIR__."/helpers/navigationHelpers.php");

class MultipartIDNumber extends IDNumber {
	# -------------------------------------------------------
	/**
	 * Initialize the plugin
	 *
	 * @param string $format A format to set as current [Default is null]
	 * @param mixed $type A type to set a current [Default is __default__] 
	 * @param string $value A value to set as current [Default is null]
	 * @param Db $db A database connection to use for all queries. If omitted a new connection (may be pooled) is allocated. [Default is null]
	 */
	public function __construct($format=null, $type=null, $value=null, $db=null) {
		parent::__construct($format, $type, $value, $db);
	}
	# -------------------------------------------------------
	/**
	 * Return separator string for current format
	 *
	 * @return string Separator, or "." if no separator setting is present
	 */
	public function getSeparator() {
		return $this->getFormatProperty('separator', ['default' => '.']);
	}
	# -------------------------------------------------------
	/**
	 * Determine if the specified format and type contain a SERIAL element its last element; that is, that the format and type 
	 * is designed as an auto incrementing sequence with 0 or more prefix elements.
	 *
	 * @param string $format A format to test. If omitted the current format is used. [Default is null]
	 * @param string $type A type to test. If omitted the current type is used. [Default is null]
	 * @return bool
	 */
	public function isSerialFormat($format=null, $type=null) {
		if(!$format) { $format = $this->getFormat(); }
		if(!$type) { $type = $this->getType(); }
		$format = mb_strtolower($format);
		$type = mb_strtolower($type);
		
		if (!$this->isValidType($type, $format)) { $type = '__default__'; }
		return $this->formatHas('SERIAL', null, $format, $type, ['checkLastElementOnly' => true]);
	}
	# -------------------------------------------------------
	/**
	 * Returns true if the current format is an extension of $format
	 * That is, the current format is the same as the $form with an auto-generated
	 * extra element such that the system can auto-generate unique numbers using a $format
	 * compatible number as the basis. This is mainly used to determine if the system configuration
	 * is such that object numbers can be auto-generated based upon lot numbers.
	 *
	 * @param string $format
	 * @param string $type [Default is __default__]
	 * @return bool
	 */
	public function formatIsExtensionOf($format, $type='__default__') {
		$format = mb_strtolower($format);
		$type = mb_strtolower($type);
		
		if (!$this->isSerialFormat()) {
			return false;	// If this format doesn't end in a SERIAL element it can't be autogenerated.
		}
		if (!$this->isValidFormat($format)) {
			return false;	// specifed format does not exist
		}
		if (!$this->isValidType($type)) {
			return false;	// specifed type does not exist
		}

		$base_elements = $this->formats[$format][$type]['elements'];
		$ext_elements = $this->getElements();

		if (sizeof($ext_elements) != (sizeof($base_elements) + 1)) {
			return false;	// extension should have exactly one more element than base
		}

		$num_elements = sizeof($base_elements);
		for($i=0; $i < $num_elements; $i++) {
			$base_element = array_shift($base_elements);
			$ext_element = array_shift($ext_elements);

			if ($base_element['type'] != $ext_element['type']) { return false; }
			if ($base_element['width'] > $ext_element['width']) { return false; }

			switch($base_element['type']) {
				case 'LIST':
					if (!is_array($base_element['values']) || !is_array($ext_element['values'])) { return false; }
					if (sizeof($base_element['values']) != sizeof($ext_element['values'])) { return false; }
					for($j=0; $j < sizeof($base_element['values']); $j++) {
						if ($base_element['values'][$j] != $ext_element['values'][$j]) { return false; }
					}
					break;
				case 'CONSTANT';
					if ($base_element['value'] != $ext_element['value']) { return false; }
					break;
				case 'NUMERIC':
					if ($base_element['minimum_length'] < $ext_element['minimum_length']) { return false; }
					if ($base_element['maximum_length'] > $ext_element['maximum_length']) { return false; }
					if ($base_element['minimum_value'] < $ext_element['minimum_value']) { return false; }
					if ($base_element['maximum_value'] > $ext_element['maximum_value']) { return false; }
					break;
				case 'ALPHANUMERIC':
					if ($base_element['minimum_length'] < $ext_element['minimum_length']) { return false; }
					if ($base_element['maximum_length'] > $ext_element['maximum_length']) { return false; }
					break;
				case 'FREE':
					if ($base_element['minimum_length'] < $ext_element['minimum_length']) { return false; }
					if ($base_element['maximum_length'] > $ext_element['maximum_length']) { return false; }
					break;
			}
		}

		return true;
	}
	# -------------------------------------------------------
	/**
	 * Breaks apart value using configuration of current format and type. When a format type specifies a separator this is generally
	 * equivalent to explode()'ing the value on the separator, except when PARENT elements (which may contain the separator) are configured.
	 * explodeValue() can also split values when no separator is configured, using configured element widths to determine boundaries.
	 *
	 * @param string $value
	 * @return array List of values
	 */
	protected function explodeValue($value) {
		$separator = $this->getSeparator();
		if ($separator && $this->formatHas('PARENT', 0)) {
			// starts with PARENT element so explode in reverse since parent value may include separators
			$v_proc = preg_replace("!^".preg_quote($this->getParentValue(), '!')."!", "_PARENT_", $value);
			$element_vals = explode($separator, $v_proc);

			$i = 0;
			foreach ($this->getElements() as $element_info) {
				switch ($element_info['type']) {
					case 'PARENT':
						$element_vals[$i] = $this->getParentValue();
						break;
					default:
						if(!array_key_exists($i, $element_vals)) { $element_vals[$i] = null; }
						break;
				}
				$i++;
			}
			$element_vals = array_map(function($v) { return preg_replace("!^_PARENT_!", '', $v); }, $element_vals);
		} elseif ($separator) {
			// Standard operation, use specified non-empty separator to split value
			$element_vals = explode($separator, $value);
		} else {
			// Separator is explicitly set to empty string, so use element widths to split value
			$element_vals = [];
			$strpos = 0;
			foreach ($this->getElements() as $element_info) {
				switch ($element_info['type']) {
					case 'LIST':
						// The element has an implicit width depending on the selected value in a list
						$matching_value = null;
						foreach ($element_info['values'] as $v) {
							if (substr($value, $strpos, mb_strlen($v)) === $v && (is_null($matching_value) || mb_strlen($matching_value) < mb_strlen($v))) {
								// We have a match, and it is either the first match or the longest match so far
								$matching_value = $v;
							}
						}
						$width = !is_null($matching_value) ? mb_strlen($matching_value) : null;
						break;
					case 'CONSTANT':
						// The element has an implicit width because it is a constant, so read the width of the constant
						$width = mb_strlen($element_info['value']);
						break;
					case 'SERIAL':
					case 'YEAR':
					case 'MONTH':
					case 'DAY':
					case 'NUMERIC':
						// Match a sequence of numeric digits
						$width = mb_strlen(preg_replace('/^(\d+).*$/', '$1', substr($value, $strpos)));
						break;
					case 'ALPHANUMERIC':
						// Match a sequence of alphanumeric characters
						$width = mb_strlen(preg_replace('/^([A-Za-z0-9]+).*$/', '$1', substr($value, $strpos)));
						break;
					case 'FREE':
					case 'PARENT':
					default:
						// Match free text
						$width = null;
				}
				if (isset($element_info['width'])) {
					// Use the configured width as either a fallback or a maximum
					$width = is_null($width) ? intval($element_info['width']) : min($width, intval($element_info['width']));
				}
				// Take the calculated width from the input value as the element value; if $width is null, use the remainder
				// of the input string
				$element_vals[] = substr($value, $strpos, $width);
				$strpos = is_null($width) ? mb_strlen($value) : $strpos + $width;
			}
		}
		return $element_vals;
	}
	# -------------------------------------------------------
	/**
	 * Validate value against current format and return list of error messages. 
	 *
	 * @param string $value A value to validate.
	 * @return array List of validation errors for value when applied to current format. Empty array if no error.
	 */
	public function validateValue($value) {
		$elements = $this->getElements();
		if (!is_array($elements)) { return []; }

		$sep = $this->getSeparator();
		$pv = $this->getParentValue();
		if((strlen($pv) > 0) && preg_match('!^'.preg_quote($pv.$sep, '!').'!u', $value)) {
			$npv  = preg_replace('!^'.preg_quote($pv.$sep, '!').'!u', '', $value);
			$element_vals = $sep ? explode($sep, $npv) : [$npv];
			array_unshift($element_vals, $pv);
		} else {
			$element_vals = $this->explodeValue($value);
		}
		$i = 0;
		$element_errors = [];
		foreach($elements as $ename => $info) {
			$v = $element_vals[$i] ?? null;
			$value_len = mb_strlen($v);

			switch($info['type']) {
				case 'LIST':
					if (!in_array($v, $info['values'])) {
						$element_errors[$ename] = _t("'%1' is not valid for %2", $v, $info['description']);
					}
					break;
				case 'SERIAL':
					if ($v) {
						$allow_suffix = (bool)($info['allowsuffix'] ?? null);
						$prefix = $info['prefix'] ?? '';
						if (!preg_match($allow_suffix ? "/^{$prefix}([0-9]+[^0-9]+.*|[0-9]+)$/" : "/^{$prefix}[0-9]+$/", $v)) {
							$element_errors[$ename] = _t("'%1' is not valid for %2; only numbers are allowed", $v, $info['description']);
						}
					}
					break;
				case 'CONSTANT':
					if ($v && ($v != $info['value'])) {
						$element_errors[$ename] = _t("%1 must be set to %2; was %3", $info['description'], $info['value'], $v);
					}
					break;
				case 'FREE':
					if (isset($info['minimum_length']) && ($value_len < $info['minimum_length'])) {
						if($info['minimum_length'] == 1) {
							$element_errors[$ename] = _t("%1 must not be shorter than %2 character", $info['description'], $info['minimum_length']);
						} else {
							$element_errors[$ename] = _t("%1 must not be shorter than %2 characters", $info['description'], $info['minimum_length']);
						}
					}
					if (isset($info['maximum_length']) && ($value_len > $info['maximum_length'])) {
						if($info['minimum_length'] == 1) {
							$element_errors[$ename] = _t("%1 must not be longer than %2 character", $info['description'], $info['maximum_length']);
						} else {
							$element_errors[$ename] = _t("%1 must not be longer than %2 characters", $info['description'], $info['maximum_length']);
						}
					}
					break;
				case 'NUMERIC':
					if (!preg_match("/^[\d]+[a-zA-Z]{0,1}$/", $v)) {
						$element_errors[$ename] = _t("%1 must be a number", $info['description']);
					}
					if (isset($info['minimum_value']) && ($v < $info['minimum_value'])) {
						$element_errors[$ename] = _t("%1 must not be less than %2", $info['description'], $info['minimum_value']);
					}
					if (isset($info['maximum_value']) && ($v > $info['maximum_value'])) {
						$element_errors[$ename] = _t("%1 must not be more than %2", $info['description'], $info['maximum_value']);
					}
					if (isset($info['minimum_length']) && ($value_len < $info['minimum_length'])) {
						if ($info['minimum_length'] == 1) {
							$element_errors[$ename] = _t("%1 must not be shorter than %2 character", $info['description'], $info['minimum_length']);
						} else {
							$element_errors[$ename] = _t("%1 must not be shorter than %2 characters", $info['description'], $info['minimum_length']);
						}
					}
					if (isset($info['maximum_length']) && ($value_len > $info['maximum_length'])) {
						if ($info['maximum_length'] == 1) {
							$element_errors[$ename] = _t("%1 must not be longer than %2 character", $info['description'], $info['maximum_length']);
						} else {
							$element_errors[$ename] = _t("%1 must not be longer than %2 characters", $info['description'], $info['maximum_length']);
						}
					}
					break;
				case 'ALPHANUMERIC':
					if ($v != '' && !preg_match("/^[A-Za-z0-9]+$/", $v)) {
						$element_errors[$ename] = _t("%1 must consist only letters and numbers", $info['description']);
					}
					if (isset($info['minimum_length']) && ($value_len < $info['minimum_length'])) {
						if ($info['minimum_length'] == 1) {
							$element_errors[$ename] = _t("%1 must not be shorter than %2 character", $info['description'], $info['minimum_length']);
						} else {
							$element_errors[$ename] = _t("%1 must not be shorter than %2 characters", $info['description'], $info['minimum_length']);
						}
					}
					if (isset($info['maximum_length']) && ($value_len > $info['maximum_length'])) {
						if ($info['maximum_length'] == 1) {
							$element_errors[$ename] = _t("%1 must not be longer than %2 character", $info['description'], $info['maximum_length']);
						} else {
							$element_errors[$ename] = _t("%1 must not be longer than %2 characters", $info['description'], $info['maximum_length']);
						}
					}
					break;
				case 'YEAR':
					$tmp = getdate();
					if ($v != '') {
						if ($info['width'] == 2) {
							if(($v < 0) || ($v > 99)){
								$element_errors[$ename] = _t("%1 must be a valid two-digit year", $info['description']);
							}
						} elseif ((($v < 1000) || ($v > ($tmp['year'] + 10))) || ($v != intval($v))) {
							$element_errors[$ename] = _t("%1 must be a valid year", $info['description']);
						}
					}
					break;
				case 'MONTH':
					if ($v != '') {
						if ((($v < 1) || ($v > 12)) || ($v != intval($v))) {
							$element_errors[$ename] = _t("%1 must be a valid numeric month (between 1 and 12)", $info['description']);
						}
					}
					break;
				case 'DAY':
					if ($v != '') {
						if ((($v < 1) || ($v > 31)) || ($v != intval($v))) {
							$element_errors[$ename] = _t("%1 must be a valid numeric day (between 1 and 31)", $info['description']);
						}
					}
					break;
				default:
					# noop
					break;

			}
			$i++;
		}
		return $element_errors;
	}
	# -------------------------------------------------------
	/**
	 * Check that value is valid for the current format
	 *
	 * @param string $value [Default is null - use current value]
	 * @return bool
	 */
	public function isValidValue($value=null) {
		return $this->validateValue(!is_null($value) ? $value : $this->getValue());
	}
	# -------------------------------------------------------
	/**
	 * Get next integer value in sequence for the specified SERIAL element
	 *
	 * @param string $element_name
	 * @param mixed $value [Default is null]
	 * @return int Next value for SERIAL element or the string "ERR" on error
	 */
	public function getNextValue($element_name, $value=null) {
		if (!$value) { $value = $this->getValue(); }
		$element_info = $this->getElementInfo($element_name);

		$table = $this->getFormat();
		if(!Datamodel::tableExists($table)) { return 'ERR'; }
		$field = Datamodel::getTableProperty($table, 'ID_NUMBERING_ID_FIELD');
		if(!$field) { return 'ERR'; }
		$sort_field = Datamodel::getTableProperty($table, 'ID_NUMBERING_SORT_FIELD');
		if (!$sort_field) { $sort_field = $field; }

		$separator = $this->getSeparator();
		$elements = $this->getElements();
		
		$is_parent = $is_serial = null;

		if ($value == null) {
			$element_vals = [];
			$i = 0;
			foreach($elements as $ename => $element_info) {
				if ($ename == $element_name) { break; }
				switch($element_info['type']) {
					case 'CONSTANT':
						$element_vals[] = $element_info['value'];
						break;
					case 'YEAR':
					case 'MONTH':
					case 'DAY':
						$date = getDate();
						if ($element_info['type'] == 'YEAR') {
							if ($element_info['width'] == 2) {
								$date['year'] = substr($date['year'], 2, 2);
							}
							$element_vals[] = $date['year'];
						}
						if ($element_info['type'] == 'MONTH') { $element_vals[]  = $date['mon']; }
						if ($element_info['type'] == 'DAY') { $element_vals[]  = $date['mday']; }
						break;
					case 'LIST':
						if ($element_info['default']) {
							$element_vals[] = $element_info['default'];
						} else {
							if (is_array($element_info['values'])) {
								$element_vals[] = array_shift($element_info['values']);
							}
						}
						break;
					case 'PARENT':
						$is_parent = $i;
						$element_vals[] = $this->getParentValue();
						break;
					case 'INHERIT':
						$pv = $this->getParentValue();
						$pv_tmp = explode($separator, $pv);
						$element_vals[] = $pv[$i] ?? null;
						break;
					case 'SERIAL':
						$element_vals[] = '';
						break;
					default:
						$element_vals[] = '';
						break;
				}
				$i++;
			}
		} elseif(is_array($value)) {
			$element_vals = [];
			$i = 0;
			foreach($elements as $ename => $element_info) {
				switch($element_info['type']) {
					case 'PARENT':
						$is_parent = $i;
						$element_vals[$i] = $value[$ename] ?? null;
						break;
					case 'INHERIT':
						$pv = $this->getParentValue();
						$pv_tmp = explode($separator, $pv);
						$element_vals[$i] = $pv[$i] ?? null;
						break;
					case 'CONSTANT':
						$element_vals[$i] = $element_info['value'];
						break;
					case 'SERIAL':
						$is_serial = true;
						$element_vals[$i] = $value[$ename] ?? '';
						break;
					default:
						$element_vals[$i] = $value[$ename] ?? null;
						break;
				}
				$i++;
			}
		} else {
			$element_vals = $this->explodeValue($value);
			
			$i = 0;
			foreach($elements as $ename => $element_info) {
				switch($element_info['type']) {
					case 'PARENT':
						$is_parent = $i;
						break;
					case 'INHERIT':
						$pv = $this->getParentValue();
						$pv_tmp = explode($separator, $pv);
						$element_vals[$i] = $pv[$i] ?? null;
						break;
					case 'CONSTANT':
						$element_vals[$i] = $element_info['value'];
						break;
					case 'SERIAL':
						$is_serial = true;
						if(!isset($element_vals[$i])) { $element_vals[$i] = ''; }
						break;
				}
				$i++;
			}
		}

		if(!is_null($is_parent)) {
			$this->isChild(true, $element_vals[$is_parent]);
		}

		$tmp = [];
		$i = 0;
		$blank_count = 0;
		foreach($elements as $ename => $element_info) {
			if ($ename == $element_name) { break; }
			$v = array_shift($element_vals);
			if(is_array($v)) { $v = join($separator, $v); }
			if (!strlen($v)) { $blank_count++; }
			$tmp[] = $v;
			$i++;
		}
		if ($blank_count > 0) {
			return ($element_info['prefix'] ?? '').((($zeropad_to_length = caGetOption('zeropad_to_length', $element_info, null, ['castTo' => 'int'])) > 0) ? sprintf("%0{$zeropad_to_length}d", 1) : 1);
		}

		$stub = trim(join($separator, $tmp));

		$this->db->dieOnError(false);

		// Get the next number based upon field data
		$type_id = null;
		$type_limit_sql = '';
		
		$params = [];
		
		if($stub === '') {
			$field_limit_sql = "{$field} <> ''";
		} elseif($is_serial) {
			$field_limit_sql = "{$field} REGEXP ?";
			$params = [preg_quote($stub.$separator, "!").'[0-9]+$'];
		} else {
			$field_limit_sql = "{$field} LIKE ?";
			$params = [$stub.$separator.'%'];
		} 
		
		if (!($t_instance = Datamodel::getInstanceByTableName($table, true))) { return 'ERR'; }
		if ((bool)($element_info['sequence_by_type'] ?? false)) {
			$stypes = is_array($element_info['sequence_by_type']) ? $element_info['sequence_by_type'] : [$element_info['sequence_by_type']];
			$sequence_by_types = caMakeTypeIDList($table, $stypes, ['dontIncludeSubtypesInTypeRestriction' => (bool)($element_info['dont_include_subtypes'] ?? false)]);
			$type = $this->getType();
			if ($type == '__default__') {
			    $types = $this->getTypes(); 
			    
			    $exclude_type_ids = [];
			    foreach($types as $type) {
			        if ($type == '__default__') { continue; }
			        if ($type_id = (int)$t_instance->getTypeIDForCode($type)) {
			            $exclude_type_ids[] = $type_id;
			        }
			    }
			    if (sizeof($exclude_type_ids) > 0) {
			        $type_limit_sql = " AND type_id NOT IN (?)";
			        $params[] = $exclude_type_ids;
			    }
			} elseif(is_array($sequence_by_types) && sizeof($sequence_by_types)) {
				$type_limit_sql = " AND type_id IN (?)";
				$params[] = $sequence_by_types;
			} elseif($type_id = (int)$t_instance->getTypeIDForCode($type)) {
		        $type_limit_sql = " AND type_id = ?";
		        $params[] = $type_id;
		    }
		}
		
		$deleted_limit_sql = ($t_instance->hasField('deleted') ? " AND (deleted = 0)" : '');
		
		if ($qr_res = $this->db->query("
			SELECT {$field} FROM {$table}
			WHERE
				{$field_limit_sql}
				{$type_limit_sql}
				{$deleted_limit_sql}
			ORDER BY
				{$sort_field} DESC 
			LIMIT 1
		", $params)) {
			if ($this->db->numErrors()) {
				return "ERR";
			}
			// Figure out what the sequence (last) number in the multipart number taken from the field is...
			if ($qr_res->numRows()) {
				while($qr_res->nextRow()) {
					$tmp = $this->explodeValue($qr_res->get($field));
					
					if($element_info['prefix'] ?? null) { 
						$tmp[$i] = preg_replace("!^".$element_info['prefix']."!", "", $tmp[$i]);
					}
					if(is_numeric($tmp[$i]) && (intval($tmp[$i]) < pow(2,64))) {
						$num = intval($tmp[$i]) + 1;
						break;
					}
				}
				if ($num == '') { $num = 1; }
				if (is_array($tmp) && (sizeof($tmp) > 1)) {
					array_pop($tmp);
					$stub = join($separator, $tmp);
				} else {
					$stub = '';
				}
			} else {
				$num = 1;
			}

			// Now get the last used sequence number for this "stub"
			$max_num = 0; 

			// Make the new number one more than the last used number if it is less than the last
			// (this prevents numbers from being reused when records are deleted or renumbered)
			if ($num <= $max_num) {
				$num = $max_num + 1;
			}
			
			if(isset($element_info['minimum']) && (($min = (int)$element_info['minimum']) > 0) && ($num < $min)) { 
				$num = $min;
			}
			
			if (($zeropad_to_length = caGetOption('zeropad_to_length', $element_info, null, ['castTo' => 'int'])) > 0) {
				$num = sprintf("%0{$zeropad_to_length}d", $num);
			} 
			
			return ($element_info['prefix'] ?? '').$num;
		} else {
			return 'ERR'; 
		}
	}
	# -------------------------------------------------------
	/**
	 * Returns sortable value padding according to the format of the specified format and type
	 *
	 * @param string $value Value from which to derive the sortable value. If omitted the current value is used. [Default is null]
	 * @return string The sortable value
	 */
	public function getSortableValue($value=null) {
		$separator = $this->getSeparator();
		if (!is_array($elements_normal_order = $this->getElements())) { $elements_normal_order = []; }
		$element_names_normal_order = array_keys($elements_normal_order);

		if (
			!($elements = $this->getElementOrderForSort()) || 
			(sizeof(array_intersect($elements, $element_names_normal_order)) !== sizeof($element_names_normal_order))
		) { $elements = $element_names_normal_order; }
		$element_values = $this->explodeValue($value ?: $this->getValue());
		$output = [];

		foreach ($elements as $element) {
			$element_info = $elements_normal_order[$element] ?? null;
			$i = array_search($element, $element_names_normal_order);
			$padding = 20;
			
			$v = $element_values[$i] ?? null;
			if(($i === (sizeof($element_names_normal_order) - 1)) && (sizeof($element_values) > sizeof($element_names_normal_order))) {	// last item with extra elements
				$extra_elements = array_splice($element_values, $i + 1);
				$v .= $separator.join($separator, $extra_elements);
			}
			
			$prefix = $element_info['prefix'] ?? null;
			switch($element_info['type']) {
				case 'LIST':
					$w = $padding - mb_strlen($v);
					if ($w < 0) { $w = 0; }
					$output[] = str_repeat(' ', $w).$v;
					break;
				case 'CONSTANT':
					$len = mb_strlen($element_info['value']);
					if ($padding < $len) { $padding = $len; }
					$repeat_len = ($padding - mb_strlen($v));
					$n = $padding - mb_strlen($v);
					$output[] = (($repeat_len > 0) ? (($n >= 0) ? str_repeat(' ', $n) : '') : '').$v;
					break;
				case 'FREE':
				case 'ALPHANUMERIC':
					$tmp = preg_split('![^A-Za-z0-9]+!',  $v);

					$raw_output = [];
					while(sizeof($tmp)) {
						$piece = array_shift($tmp);
						if (preg_match('!^([\d]+)(.*)!', $piece, $matches)) {
							$piece = $matches[1];

							if (sizeof($matches) >= 3) {
								array_unshift($tmp, $matches[2]);
							}
						}
						$pad_len = $padding - mb_strlen($piece);

						if ($pad_len >= 0) {
							if (is_numeric($piece)) {
								$raw_output[] = str_repeat(' ', $pad_len).$matches[1];
							} else {
								$raw_output[] = $piece.str_repeat(' ', $pad_len);
							}
						} else {
							$raw_output[] = $piece;
						}
					}
					$output[] = join('', $raw_output); 
					break;
				case 'SERIAL':
				case 'NUMERIC':
					if ($padding < $element_info['width']) { $padding = $element_info['width']; }
					
					if($allow_prefix = (bool)($element_info['prefix'] ?? null)) {
						$v = preg_replace("![^0-9]+$!", "", $v);
					}
					if($prefix) {
						$tmp = mb_substr($v, mb_strlen($prefix));
						if(is_numeric($tmp)) {
							$v = $prefix.str_pad($tmp, $padding - mb_strlen($prefix), "0", STR_PAD_LEFT);
						};
					}
					if ($zeropad_to_length = caGetOption('zeropad_to_length', $element_info, null, ['castTo' => 'int'])) {
						$v = str_pad($v, $zeropad_to_length, "0", STR_PAD_LEFT);
					}
					$n = $padding - strlen($v);
					
					$output[] = (($n >= 0) ? str_repeat(' ', $n) : '').$v;
					break;
				case 'YEAR':
					$p = (($element_info['width'] == 2) ? 2 : 4) - mb_strlen($v);
					if ($p < 0) { $p = 0; }
					$output[] = str_repeat(' ', $p).$v;
					break;
				case 'MONTH':
				case 'DAY':
					$p = 2 - mb_strlen($v);
					if ($p < 0) { $p = 0; }
					$n = 2 - $p;
					$output[] = (($n >= 0) ? str_repeat(' ', $n) : '').$v;
					break;
				case 'PARENT':
					$tmp = explode($separator, $v);
					
					foreach($tmp as $t) {
						if(preg_match("!^([A-Z]+)([\d]+)$!i", $t, $m)) {
							$t = $m[1].str_pad($m[2], $padding - strlen($m[1]), "0", STR_PAD_LEFT);
						}
						$n = $padding - mb_strlen($t);
						$output[] = ((($n >= 0) ? str_repeat(' ', $n) : '').$t);
					}
					break;
				default:
					$n = $padding - mb_strlen($v);
					$output[] = (($n >= 0) ? str_repeat(' ', $n) : '').$v;
					break;

			}
		}
		return join($separator, $output);
	}
	# -------------------------------------------------------
	/**
	 * Returns value as sortable integer
	 *
	 * @param string $value Value from which to derive the sortable value. If omitted the current value is used. [Default is null]
	 * @return int The sortable value
	 */
	public function getSortableNumericValue(?string $value=null) : ?int {
		$separator = $this->getSeparator();
		if (!is_array($elements_normal_order = $this->getElements())) { $elements_normal_order = []; }
		$element_names_normal_order = array_keys($elements_normal_order);

		if (
			!($elements = $this->getElementOrderForSort()) || 
			(sizeof(array_intersect($elements, $element_names_normal_order)) !== sizeof($element_names_normal_order))
		) { $elements = $element_names_normal_order; }
		$element_values = $this->explodeValue($value ?: $this->getValue());
		$output = [];
		
		$n = 0;
		foreach ($elements as $element) {
			$element_info = $elements_normal_order[$element] ?? null;
			$i = array_search($element, $element_names_normal_order);
			$v = $element_values[$i] ?? null;
			
			$range = caGetOption('range', $element_info, 5);
			$precision = caGetOption('precision', $element_info, 2);
			
			$values = strlen($v) ? array_reverse($this->_valueToSortableInts($v, $range, $precision)) : [];
			$p = 0;
			foreach($values as $v) {
				$z = (int)(($v * pow(10, $p)));
				$n += $z;
				$p += $range;
			}
		}
		return (int)$n;
	}
	# -------------------------------------------------------
	/**
	 *
	 */
	private function _valueToSortableInts(string $value, int $range, int $precision) : array {
		$sub_values = preg_split('![^A-Za-z\d\.]+!', $value);

		$ints = [];
		foreach($sub_values as $sv) {
			if(is_numeric($sv)) {
				// convert number to sortable int
				$ints[] = $this->_numToSortableInt((float)$sv, $range, $precision);
			} elseif(preg_match('!^([\d]+)([A-Za-z]+)$!', $sv, $m)) {
				// Treat trailing letters on a numeric values as right-of-decimal (Eg. a sub-identifier)
				$ints[] = $this->_numToSortableInt((float)($m[0].'.'.$this->_stringToSortableInt($m[1], $range, $precision)), $range, $precision);
			} elseif(strpos($sv, '.') !== false) {
				$x = explode('.', $sv);
				while(sizeof($x) > 0) {
					$svp = array_shift($x);
					if(is_numeric($svp)) {
						$ints[] = $this->_numToSortableInt((float)$svp, $range, $precision);
					} else {
						// Treat as base-36 number
						$ints[] = $this->_stringToSortableInt($svp, $range, $precision);
					}
				}
			} else {
				// Treat as base-36 number
				$ints[] = $this->_stringToSortableInt($sv, $range, $precision);
			}
		}
		return $ints;
	}
	# -------------------------------------------------------
	/**
	 *
	 */
	private function _stringToSortableInt(string $value, int $range, int $precision) : int {
		$n = [];
		for($i=0; $i < strlen($value); $i++) {
			$c = $value[$i];
			if(is_numeric($c)) {
				$n[] = $c;
			} else {
				$n[] = ord(strtoupper($c)) - 65 + 10;
			}
		}
		$v = 0;
		$p = 0;
		foreach(array_reverse($n) as $x) {
			$v += ($x * pow(10, $p));
			$p++;
		}
		$v *= pow(10, $precision);
		return (int)$v;
	}
	# -------------------------------------------------------
	/**
	 *
	 */
	private function _numToSortableInt(float $value, int $range, int $precision) : int {
		return (int)str_replace('.', '', $value * pow(10, $precision));
	}
	# -------------------------------------------------------
	/**
	 * Return a list of modified identifier values suitable for search indexing according to the format of the specified format and type
	 * Modifications include removal of leading zeros, stemming and more.
	 *
	 * @param string $value Value from which to derive the index values. If omitted the current value is used. [Default is null]
	 * @return array Array of strings for indexing
	 */
	public function getIndexValues($value=null, $options=null) {
		$separator = $this->getSeparator();
		if (!is_array($elements_normal_order = $this->getElements())) { $elements_normal_order = []; }
		$elements = array_keys($elements_normal_order);
		$element_values = $this->explodeValue($value ?: $this->getValue());
		
		$output = [join($separator, $element_values)];
		$i = $max_value_count = 0;

		// element-specific processing
		foreach($elements as $element) {
			$element_info = $elements_normal_order[$element];
			$i = array_search($element, $elements);
            if(!is_array($output[$i] ?? null)) { $output[$i] = []; }
            if(!isset($element_values[$i])) { $element_values[$i] = null; }
			switch($element_info['type']) {
				case 'LIST':
					$output[$i] = array($element_values[$i]);
					break;
				case 'CONSTANT':
					$output[$i] = array($element_values[$i]);
					break;
				case 'FREE':
				case 'ALPHANUMERIC':
					$output[$i] = array($element_values[$i]);
					if ((int)$element_values[$i] > 0) {
						$output[$i][] = (int)$element_values[$i];
					}
					break;
				case 'SERIAL':
				case 'NUMERIC':
				case 'MONTH':
				case 'DAY':
				case 'YEAR':
					$output[$i] = array($element_values[$i]);
					if (preg_match('!^([0]+)([\d]+)$!', $element_values[$i], $matches)) {
					    if(is_array($matches[1])) {
                            for($i=0; $i < sizeof($matches[1]); $i++) {
                                $output[$i][] = substr($element_values[$i], $i);
                            }
                        }
					}
					break;
				default:
					$output[$i] = array($element_values[$i]);
					break;
			}

			if ($max_value_count < sizeof($output[$i])) { $max_value_count = sizeof($output[$i]); }
		}

		$output_values = [];

		// Generate permutations from element-specific processing
		for($c=0; $c < $max_value_count; $c++) {
			$output_values_buf = [];

			foreach($elements as $element) {
				if (!isset($output[$i][0])) { continue; }

				$i = array_search($element, $elements);
				if (isset($output[$i][$c])) {
					$output_values_buf[] = $output[$i][$c];
				} else {
					$output_values_buf[] = $output[$i][0];
				}
			}

			$output_values[] = join($separator, $output_values_buf);
		}

		// generate incremental "stems" of identifier by exploding on punctuation
		if(preg_match_all("![^A-Za-z0-9]+!", $value, $delimiters)) {
			$element_values = preg_split("![^A-Za-z0-9]+!", $value);
			$acc = [];
			foreach($element_values as $element_value) {
				$acc[] = $element_value;
				$output_values[] = join('', $acc);
				if (is_numeric($element_value)) {
					array_pop($acc);
					$acc[] = $element_value;
					$output_values[] = join('', $acc);
				}
				if (sizeof($delimiters[0]) > 0) { $acc[] = array_shift($delimiters[0]); }
			}
		}

		// generate versions without leading zeros
		$output_values[] = preg_replace("!^[0]+!", "", $value);	// remove leading zeros
		if (preg_match_all("!([^0-9]+)([0]+)!", $value, $matches)) {
			$value_proc = $value;
			for($x=0; $x < sizeof($matches[0]); $x++) {
				$value_proc = str_replace($matches[0][$x], $matches[1][$x], $value_proc);
			}
			$output_values[] = $value_proc;
		}

		// generate version without trailing letters after number (eg. KHF-134b => KHF-134)
		$tmp = $output_values;
		foreach($tmp as $value_proc) {
			$output_values[] = preg_replace("!([\d]+)[A-Za-z]+$!", "$1", $value_proc);
		}
		
		$output_values = array_unique($output_values);
		
		// generate tokenized version
		if($tokens = caTokenizeString($value)){
			$output_values = array_merge($output_values, $tokens);
		}
		
		if (isset($options['INDEX_IDNO_PARTS']) || (is_array($options) && (in_array('INDEX_IDNO_PARTS', $options)))) {
		    if (is_array($delimiters = caGetOption('IDNO_DELIMITERS', $options, $this->getSeparator() ? [$this->getSeparator()]: null)) && sizeof($delimiters)) {
		        $output_values = array_merge($output_values, preg_split("![".preg_quote(join('', $delimiters), "!")."]!", $value));
		    }
		}
		return array_values(array_unique(array_filter($output_values, 'strlen')));
	}
	# -------------------------------------------------------
	# User interace (HTML)
	# -------------------------------------------------------
	/**
	 * Return HTML form elements for all elements using the current format, type and value
	 *
	 * @param string $name Name of form element. Is used as a prefix for each form element. The number element name will be used as a suffix for each.
	 * @param array $errors Passed-by-reference array. Will contain any validation errors for the value, indexed by element.
	 * @param array $options Options include:
	 *		id_prefix = Prefix to add to element ID attributes. [Default is null]
	 *		for_search_form = Generate a blank form for search. [Default is false]
	 *		show_errors = Include error messages next to form elements. [Default is false]
	 *		error_icon = Icon to display next to error messages; should be ready-to-display HTML. [Default is null]
	 *		readonly = Make all form elements read-only. [Default is false]
	 *		request = the current request (an instance of RequestHTTP) [Default is null]
	 *		check_for_dupes = perform live checking for duplicate numbers. [Default is false]
	 *		progress_indicator = URL for spinner graphic to use while running duplicate number check. [Default is null] 
	 *		table = Table to perform duplicate number check in. [Default is null]
	 *		search_url = Search service URL to use when performing duplicate number check. [Default is null]
	 *		row_id = ID of row to exclude from duplicate number check (typically the current record id). [Default is null]
	 *		context_id = context ID of row to exclude from duplicate number check (typically the current record context). [Default is null]
	 * @return string HTML output
	 */
	public function htmlFormElement($name, &$errors=null, $options=null) {
		if (!is_array($options)) { $options = []; }
		$id_prefix = isset($options['id_prefix']) ? $options['id_prefix'] : null;
		$generate_for_search_form = isset($options['for_search_form']) ? true : false;

		$errors = $this->validateValue($this->getValue());
		$separator = $this->getSeparator();
		$element_values = $this->explodeValue($this->getValue());
		
		$dont_allow_editing = isset($options['row_id']) && ($options['row_id'] > 0) && $this->config->exists($this->getFormat().'_dont_allow_editing_of_codes_when_in_use') && (bool)$this->config->get($this->getFormat().'_dont_allow_editing_of_codes_when_in_use');
		if ($dont_allow_editing) { $options['readonly'] = true; }

		if (!is_array($elements = $this->getElements())) { $elements = []; }

		$element_controls = $element_control_names = [];
		$i=0;

		$next_in_seq_is_present = false;
		foreach($elements as $ename => $info) {
			if (($info['type'] == 'SERIAL') && (($element_values[$i] ?? null) == '')) {
				$next_in_seq_is_present = true;
			}
			$options['index'] = $i;
			$tmp = $this->genNumberElement($ename, $name, $element_values[$i] ?? null, $id_prefix, $generate_for_search_form, $options);
			$element_control_names[] = $name.'_'.$ename;

			if (($options['show_errors']) && (isset($errors[$ename]))) {
				$error_message = preg_replace("/[\"\']+/", "", $errors[$ename]);
				if ($options['error_icon']) {
					$tmp .= "<a href='#' id='caIdno_{$id_prefix}_{$name}'>".$options['error_icon']."</a>";
				} else {
					$tmp .= "<a href='#' id='caIdno_{$id_prefix}_{$name}'>["._t('Error')."]</a>";
				}
				TooltipManager::add("#caIdno_{$id_prefix}_{$name}", "<h2>"._t('Error')."</h2>{$error_message}");
			}
			$element_controls[] = $tmp;
			$i++;
		}
		if ((sizeof($elements) < sizeof($element_values)) && (bool)$this->getFormatProperty('allow_extra_elements', array('default' => 1))) {
			$extra_values = array_slice($element_values, sizeof($elements));
			
			if (($extra_size = (int)$this->getFormatProperty('extra_element_width', array('default' => 10))) < 1) {
				$extra_size = 10;
			}
			foreach($extra_values as $i => $v) {
				$element_controls[] = "<input type='text' name='{$name}_extra_{$i}' id='{$name}_extra_{$i}' value='".htmlspecialchars($v, ENT_QUOTES, 'UTF-8')."' size='{$extra_size}'".($options['readonly'] ? ' readonly="1" ' : '').">";
				$element_control_names[] = $name.'_extra_'.$i;
			}
		}
		
		if ($this->config->exists($this->getFormat().'_dont_allow_editing_of_codes_when_in_use')) {
			if (isset($options['row_id']) && ($options['row_id'] > 0)) {
				if ($dont_allow_editing) {
					$element_controls[] =  '<span class="formLabelWarning"><i class="caIcon fa fa-info-circle fa-1x"></i> '._t('Value cannot be edited because it is in use').'</span>';	
				} else {
					$element_controls[] =  '<span class="formLabelWarning"><i class="caIcon fa fa-exclamation-triangle fa-1x"></i> '._t('Changing this value may break parts of the system configuration').'</span>';	
				}
			}
		}

		$js = '';
		if ($options['check_for_dupes'] || $next_in_seq_is_present){
			$ids = [];
			foreach($element_control_names as $v) {
				$ids[] = "'#".$id_prefix.$v."'";
			}

			$js = '<script type="text/javascript" language="javascript">'."\n// <![CDATA[\n";
			$lookup_url_info = caJSONLookupServiceUrl($options['request'], $options['table']);
			$js .= "
				caUI.initIDNoChecker({
					errorIcon: ".json_encode($options['error_icon']).",
					processIndicator: ".json_encode($options['progress_indicator']).",
					idnoStatusID: 'idnoStatus',
					lookupUrl: ".json_encode($lookup_url_info['idno']).",
					searchUrl: ".json_encode($options['search_url']).",
					idnoFormElementIDs: [".join(',', $ids)."],
					separator: ".json_encode($this->getSeparator()).",
					row_id: ".intval($options['row_id']).",
					type_id: ".intval($options['type_id']).",
					context_id: ".intval($options['context_id']).",
					parentValue: ".json_encode($this->getParentValue()).",
					checkDupes: ".(($options['check_for_dupes'] && !$next_in_seq_is_present) ? '1' : '0').",
					includesSequence: ".($next_in_seq_is_present ? '1' : '0').",

					singularAlreadyInUseMessage: ".json_encode(_t('Identifier is already in use')).",
					pluralAlreadyInUseMessage: ".json_encode(_t('Identifier is already in use %1 times')).",
					
					sequenceMessage: ".json_encode("&lt;"._t('%1 on save')."&gt;")."
				});
			";

			$js .= "// ]]>\n</script>\n";
		}

		return join($separator, $element_controls).$js;
	}
	# -------------------------------------------------------
	/**
	 * When displayed in a form for editing a multipart identifier will be composed of as many form elements as there are elements defined for the identifier format.
	 * Each form element will have a name beginning with the identifier field name and suffixed with the name of the identifier element. htmlFormValue() 
	 * will pull these values from either an incoming request or, if specified, from the value specified in the $value parameter and return it as a string.
	 * This method is identical to htmlFormValuesAs[] save that it returns a string rather than an array.
	 *
	 * @param string $name Name of the identifier field (eg. idno)
	 * @param string $value An optional value to extract form values from. If null, values are pulled from the current request. [Default is null]
	 * @param bool $dont_mark_serial_value_as_used Don't record incoming value of the new maximum for SERIAL element sequences. [Default is false]
	 * @param bool $generate_for_search_form Return array of empty values suitable for use in a search (not editing) form. [Default is false]
	 * @param bool $always_generate_serial_values Always generate new values for SERIAL elements, even if they are not set with placeholders. [Default is false]
	 * @return String Identifier from extracted from form and returned as string
	 */
	public function htmlFormValue($name, $value=null, $dont_mark_serial_value_as_used=false, $generate_for_search_form=false, $always_generate_serial_values=false) {
		$tmp = $this->htmlFormValuesAsArray($name, $value, $dont_mark_serial_value_as_used, $generate_for_search_form, $always_generate_serial_values);
		if (!($separator = $this->getSeparator())) { $separator = ''; }

		return (is_array($tmp)) ? join($separator, $tmp) : null;
	}
	# -------------------------------------------------------
	/**
	 * Generates an id numbering template (text with "%" characters where serial values should be inserted)
	 * from a value. The elements in the value that are generated as SERIAL incrementing numbers will be replaced
	 * with "%" characters, resulting is a template suitable for use with BundlableLabelableBaseModelWithAttributes::setIdnoTWithTemplate
	 * If the $pb_no_placeholders parameter is set to true then SERIAL values are omitted altogether from the returned template.
	 *
	 * Note that when the number of element replacements is limited, the elements are counted right-to-left. This means that
	 * if you limit the template to two replacements, the *rightmost* two SERIAL elements will be replaced with placeholders.
	 *
	 * @see BundlableLabelableBaseModelWithAttributes::setIdnoTWithTemplate
	 *
	 * @param string $value The id number to use as the basis of the template
	 * @param int $max_num_replacements The maximum number of elements to replace with placeholders. Set to 0 (or omit) to replace all SERIAL elements.
	 * @param bool $no_placeholders If set SERIAL elements are omitted altogether rather than being replaced with placeholder values
	 *
	 * @return string A template
	 */
	public function makeTemplateFromValue($value, $max_num_replacements=0, $no_placeholders=false) {
		$separator = $this->getSeparator();
		$values = $this->explodeValue($value);
		if (!is_array($elements = $this->getElements())) { $elements = []; }
		
		$num_serial_elements = 0;
		foreach ($elements as $element_info) {
			if ($element_info['type'] == 'SERIAL') { $num_serial_elements++; }
		}

		$i = 0;
		$num_serial_elements_seen = 0;
		
		foreach ($elements as $element_info) {
			switch($element_info['type']) {
				case 'PARENT':
					$values[$i] = $this->getParentValue();
					break;
				case 'SERIAL':
					$num_serial_elements_seen++;

					if ($no_placeholders) { 
						$values[$i] = null; 
					} elseif ($max_num_replacements <= 0) {	// replace all
						$values[$i] = '%';
					} elseif (($num_serial_elements - $num_serial_elements_seen) < $max_num_replacements) {
						$values[$i] = '%';
					}
					break;
				case 'CONSTANT':
					$values[$i] = $element_info['value'];
					break;
				case 'YEAR':
					if (caGetOption('force_derived_values_to_current_year', $element_info, false)) {
						$tmp = getdate();
						$values[$i] = $tmp['year'];
					}
					break;
				case 'MONTH':
					if (caGetOption('force_derived_values_to_current_month', $element_info, false)) {
						$tmp = getdate();
						$values[$i] = $tmp['mon'];
					}
					break;
				case 'DAY':
					if (caGetOption('force_derived_values_to_current_day', $element_info, false)) {
						$tmp = getdate();
						$values[$i] = $tmp['mday'];
					}
					break;
				case 'LIST':
				case 'FREE':
				case 'NUMERIC':
				case 'ALPHANUMERIC':
					// noop
					break;
				default:
					$values[$i] = null;
					break;
			}

			$i++;
		}
		return join($separator, $values);
	}
	# -------------------------------------------------------
	/**
	 * When displayed in a form for editing a multipart identifier will be composed of as many form elements as there are elements defined for the identifier format.
	 * Each form element will have a name beginning with the identifier field name and suffixed with the name of the identifier element. htmlFormValuesAs[] 
	 * will pull these values from either an incoming request or, if specified, from the value specified in the $value parameter and return them as an array
	 * indexed with keys that identifier name + "_" + element name.
	 *
	 * @param string $name Name of the identifier field (eg. idno)
	 * @param string $value An optional value to extract form values from. If null, values are pulled from the current request. [Default is null]
	 * @param bool $dont_mark_serial_value_as_used Don't record incoming value of the new maximum for SERIAL element sequences. [Default is false]
	 * @param bool $generate_for_search_form Return array of empty values suitable for use in a search (not editing) form. [Default is false]
	 * @param bool $always_generate_serial_values Always generate new values for SERIAL elements, even if they are not set with placeholders. [Default is false]
	 * @return array Array of values for identifer extracted from request
	 */
	public function htmlFormValuesAsArray($name, $value=null, $dont_mark_serial_value_as_used=false, $generate_for_search_form=false, $always_generate_serial_values=false, ?array $options=null) {
		if (is_null($value)) {
			if(isset($_REQUEST[$name]) && $_REQUEST[$name]) { return $_REQUEST[$name]; }
		}
		if (!is_array($elements = $this->getElements())) { 
			return (isset($_REQUEST["{$name}_extra_0"])) ? [$_REQUEST["{$name}_extra_0"]] : null; 
		}
		$return_template = caGetOption('returnTemplate', $options, false);
		$element_names = array_keys($elements);
		$separator = $this->getSeparator();
		$element_values = [];
		if ($value) {
			$tmp = $this->explodeValue($value);
			foreach ($element_names as $ename) {
				if (!sizeof($tmp)) { break; }
				$element_values[$name.'_'.$ename] = array_shift($tmp);
			}
			if ((sizeof($tmp) > 0) && (bool)$this->getFormatProperty('allow_extra_elements', array('default' => 1))) {
				$i = 0;
				foreach($tmp as $t) {
					$element_values[$name.'_extra_'.$i] = $t;
					$i++;
				}
			}
		} else {
			foreach ($element_names as $ename) {
				if(isset($_REQUEST[$name.'_'.$ename])) {
					$element_values[$name.'_'.$ename] = $_REQUEST[$name.'_'.$ename];
				}
			}
			
			if ((bool)$this->getFormatProperty('allow_extra_elements', array('default' => 1))) {
				$i = 0;
				while(true) {
					if(isset($_REQUEST[$name.'_extra_'.$i])) {
						$element_values[$name.'_extra_'.$i] = $_REQUEST[$name.'_extra_'.$i];
						$i++;
					} else {
						break;
					}
				}
			}
		}

		$isset = $is_not_empty = false;
		$tmp = [];
		foreach($elements as $ename => $info) {
			if ($info['type'] == 'SERIAL') {
				if ($generate_for_search_form) {
					$tmp[$ename] = $element_values[$name.'_'.$ename];
					continue;
				}
				$element_values[$name.'_'.$ename] = $element_values[$name.'_'.$ename] ?? null;
		
				if (!strlen($element_values[$name.'_'.$ename]) && $return_template) {
					$element_values[$name.'_'.$ename] = '%';
				}
				if (!$return_template && (($element_values[$name.'_'.$ename] == '') || ($element_values[$name.'_'.$ename] == '%') || $always_generate_serial_values)) {
					if ($element_values[$name.'_'.$ename] == '%') { $element_values[$name.'_'.$ename] = ''; }
					$tmp[$ename] = $this->getNextValue($ename, $tmp, $dont_mark_serial_value_as_used);
					$isset = $is_not_empty = true;
					continue;
				}
			} elseif(($info['type'] == 'YEAR') && !$element_values[$name.'_'.$ename]) {  // set constant
			    $date = getdate();
			    $element_values[$name.'_'.$ename] = $date['year'];
			} elseif(($info['type'] == 'MONTH') && !$element_values[$name.'_'.$ename]) {
			    $date = getdate();
			    $element_values[$name.'_'.$ename] = $date['mon'];
			} elseif(($info['type'] == 'DAY') && !$element_values[$name.'_'.$ename]) {
			    $date = getdate();
			    $element_values[$name.'_'.$ename] = $date['mday'];
			} elseif($info['type'] == 'CONSTANT') {
			    $element_values[$name.'_'.$ename] = $info['value'];
			} elseif(($info['type'] == 'LIST') && (!isset($element_values[$name.'_'.$ename]) || !$element_values[$name.'_'.$ename])) {
				if (!is_array($info['values'])) { $info['values'] = []; }
			    $element_values[$name.'_'.$ename] = $info['values'][0];
			}

			if ($generate_for_search_form) {
				if ($element_values[$name.'_'.$ename] == '') {
					$tmp[$ename] = '';
					break;
				}
			}
			$tmp[$ename] = $element_values[$name.'_'.$ename];

			if ($zeropad_to_length = caGetOption('zeropad_to_length', $info, null, ['castTo' => 'int'])) {
				$tmp[$ename] = str_pad($tmp[$ename], $zeropad_to_length, "0", STR_PAD_LEFT);
			}

			if (isset($element_values[$name.'_'.$ename])) {
				$isset = true;
			}
			if ($element_values[$name.'_'.$ename] != '') {
				$is_not_empty = true;
			}
		}
		
		if((bool)$this->getFormatProperty('allow_extra_elements', array('default' => 1))) {
			$i = 0;
			while(true) {
				if (isset($element_values[$name.'_extra_'.$i]) && ($t = $element_values[$name.'_extra_'.$i])) {
					$tmp[$name.'_extra_'.$i] = $t;
					$i++;
				} else {
					break;
				}	
			}
		}
		
		return ($isset && $is_not_empty) ? $tmp : null;
	}
	# -------------------------------------------------------
	# Generated id number element
	# -------------------------------------------------------
	/**
	 * Return width of specified element
	 *
	 * @param array $element_info Array of information about the specified element, as returned by getElements()
	 * @param int $default Default width, in characters, to use when width is not set in element info [Default is 3]
	 * @return int Width, in characters
	 */
	private function getElementWidth($element_info, $default=3) {
		$width = isset($element_info['width']) ? $element_info['width'] : 0;
		if ($width <= 0) { $width = $default; }

		return $width;
	}
	# -------------------------------------------------------
	/**
	 * Generate an individual HTML form element for a specific number element. Used by htmlFormElement() to create a set of form element for the current format type.
	 *
	 * @param string $element_name Number element to generate form element for.
	 * @param string $name Name of the identifier field (eg. idno)
	 * @param string $value An optional value to extract form values from. If null, values are pulled from the current request. [Default is null]
	 * @param string $id_prefix Prefix to add to element ID attributes. [Default is null]
	 * @param bool $generate_for_search_form Return array of empty values suitable for use in a search (not editing) form. [Default is false]
	 * @param array $options Options include:
	 *		readonly = Make form element read-only. [Default is false]
	 * @return string HTML output
	 */
	private function genNumberElement($element_name, $name, $value, $id_prefix=null, $generate_for_search_form=false, $options=null) {
		if (!($format = $this->getFormat())) {
			return null;
		}
		if (!($type = $this->getType())) {
			return null;
		}
		$element = '';

		$element_info = $this->formats[$format][$type]['elements'][$element_name];
		$element_form_name = $name.'_'.$element_name;

		$element_value = $value;
		$element_info['editable'] = $element_info['editable'] ?? false;
		
		switch($element_info['type']) {
			# ----------------------------------------------------
			case 'LIST':
				if (!is_array($element_info['values'])) { $element_info['values'] = []; }
				if (!$element_value || $element_info['editable'] || $generate_for_search_form) {
					if (!$element_value && !$generate_for_search_form) { $element_value = $element_info['default']; }
					$element = '<select name="'.$element_form_name.'" id="'.$id_prefix.$element_form_name.'">';
					if ($generate_for_search_form) {
						$element .= "<option value='' selected='selected'>-</option>";
					}
					foreach ($element_info['values'] as $value) {
						if (trim($value) === trim($element_value)) { $selected = ' selected="selected"'; } else { $selected = ''; }
						$element .= '<option value="'.$value.'"'.$selected.'>'.$value.'</option>';
					}

					if (!$generate_for_search_form) {
						if (!in_array($element_value, $element_info['values']) && strlen($element_value) > 0) {
							$element .= '<option value="'.$element_value.'" selected="selected">'.$element_value.'</option>';
						}
					}

					$element .= '</select>';
				} else {
					$element_val_proc = (in_array($element_value, $element_info['values']) ? $element_value : $element_info['values'][0]);
					$element .= '<input type="hidden" name="'.$element_form_name.'" id="'.$id_prefix.$element_form_name.'" value="'.htmlspecialchars($element_val_proc, ENT_QUOTES, 'UTF-8').'"/>'.$element_val_proc;
				}

				break;
			# ----------------------------------------------------
			case 'SERIAL':
				$width = $this->getElementWidth($element_info, 3);

				if ($generate_for_search_form) {
					$element .= '<input type="text" name="'.$element_form_name.'" id="'.$id_prefix.$element_form_name.'" value="" maxlength="'.$width.'" size="'.$width.'"'.($options['readonly'] ? ' readonly="1" ' : '').'/>';
				} elseif ($element_value == '') {
						$next_num = $this->getNextValue($element_name, null, true);
						$element .= "<span id='".$id_prefix.$element_form_name."'>&lt;"._t('%1 on save', $next_num)."&gt;</span>";
				} else {
					if ($element_info['editable']) {
						$element .= '<input type="text" name="'.$element_form_name.'" id="'.$id_prefix.$element_form_name.'" value="'.htmlspecialchars($element_value, ENT_QUOTES, 'UTF-8').'" size="'.$width.'" maxlength="'.$width.'"'.($options['readonly'] ? ' readonly="1" ' : '').'/>';
					} else {
						$element .= '<input type="hidden" name="'.$element_form_name.'" id="'.$id_prefix.$element_form_name.'" value="'.htmlspecialchars($element_value, ENT_QUOTES, 'UTF-8').'"/>'.$element_value;
					}
				}
				break;
			# ----------------------------------------------------
			case 'CONSTANT':
				$width = $this->getElementWidth($element_info, 3);

				if (!$element_value) { $element_value = $element_info['value']; }
				if ($element_info['editable'] || $generate_for_search_form) {
					$element .= '<input type="text" name="'.$element_form_name.'" id="'.$id_prefix.$element_form_name.'" value="'.htmlspecialchars($element_value, ENT_QUOTES, 'UTF-8').'" size="'.$width.'"'.($options['readonly'] ? ' readonly="1" ' : '').'/>';
				} else {
					$element .= '<input type="hidden" name="'.$element_form_name.'" id="'.$id_prefix.$element_form_name.'" value="'.htmlspecialchars($element_value, ENT_QUOTES, 'UTF-8').'"/>'.$element_value;
				}
				break;
			# ----------------------------------------------------
			case 'FREE':
			case 'NUMERIC':
			case 'ALPHANUMERIC':
				if (!$element_value && !$generate_for_search_form) { $element_value = $element_info['default'] ?? null; }
				$width = $this->getElementWidth($element_info, 3);
				if (!$element_value || $element_info['editable'] || $generate_for_search_form) {
					$element .= '<input type="text" name="'.$element_form_name.'" id="'.$id_prefix.$element_form_name.'" value="'.htmlspecialchars($element_value, ENT_QUOTES, 'UTF-8').'" size="'.$width.'" maxlength="'.$width.'"'.($options['readonly'] ? ' readonly="1" ' : '').'/>';
				} else {
					$element .= '<input type="hidden" name="'.$element_form_name.'" id="'.$id_prefix.$element_form_name.'" value="'.htmlspecialchars($element_value, ENT_QUOTES, 'UTF-8').'"/>'.$element_value;
				}
				break;
			# ----------------------------------------------------
			case 'YEAR':
			case 'MONTH':
			case 'DAY':
				$width = $this->getElementWidth($element_info, 5);
				$date = getdate();
				if ($element_value == '') {
					$v = '';
					if (!$generate_for_search_form) {
						if ($element_info['type'] == 'YEAR') { $v = ($element_info['width'] == 2) ? substr($date['year'], 2, 2) : $date['year']; }
						if ($element_info['type'] == 'MONTH') { $v = $date['mon']; }
						if ($element_info['type'] == 'DAY') { $v = $date['mday']; }
					}

					if ($element_info['editable'] || $generate_for_search_form) {
						$element .= '<input type="text" name="'.$element_form_name.'" id="'.$id_prefix.$element_form_name.'" value="'.htmlspecialchars($v, ENT_QUOTES, 'UTF-8').'" size="'.$width.'"'.($options['readonly'] ? ' readonly="1" ' : '').'/>';
					} else {
						$element .= '<input type="hidden" name="'.$element_form_name.'" id="'.$id_prefix.$element_form_name.'" value="'.htmlspecialchars($v, ENT_QUOTES, 'UTF-8').'"/>'.$v;
					}
				} else {
					if ($element_info['editable'] || $generate_for_search_form) {
						$element .= '<input type="text" name="'.$element_form_name.'" id="'.$id_prefix.$element_form_name.'" value="'.htmlspecialchars($element_value, ENT_QUOTES, 'UTF-8').'" size="'.$width.'"'.($options['readonly'] ? ' readonly="1" ' : '').'/>';
					} else {
						$element .= '<input type="hidden" name="'.$element_form_name.'" id="'.$id_prefix.$element_form_name.'" value="'.htmlspecialchars($element_value, ENT_QUOTES, 'UTF-8').'"/>'.$element_value;
					}
				}

				break;
			# ----------------------------------------------------
				case 'INHERIT':
				case 'PARENT':
				$width = $this->getElementWidth($element_info, 3);

				if ($generate_for_search_form) {
					$element .= '<input type="text" name="'.$element_form_name.'" id="'.$id_prefix.$element_form_name.'" value="" maxlength="'.$width.'" size="'.$width.'"'.($options['readonly'] ? ' readonly="1" ' : '').'/>';
				} else {
					if ($element_value == '') {
						$next_num = $this->getParentValue();
						if($element_info['type'] === 'INHERIT') {
							$pv = explode($this->getSeparator(), $next_num);
							$next_num = $pv[1];
						}
						$element .= '&lt;'._t('%1', $next_num).'&gt;'.'<input type="hidden" name="'.$element_form_name.'" id="'.$id_prefix.$element_form_name.'" value="'.htmlspecialchars($next_num, ENT_QUOTES, 'UTF-8').'"/>';
					} else {
						if ($element_info['editable']) {
							$element .= '<input type="text" name="'.$element_form_name.'" id="'.$id_prefix.$element_form_name.'" value="'.htmlspecialchars($element_value, ENT_QUOTES, 'UTF-8').'" size="'.$width.'" maxlength="'.$width.'"'.($options['readonly'] ? ' readonly="1" ' : '').'/>';
						} else {
							$element .= '<input type="hidden" name="'.$element_form_name.'" id="'.$id_prefix.$element_form_name.'" value="'.htmlspecialchars($element_value, ENT_QUOTES, 'UTF-8').'"/>'.$element_value;
						}
					}
				}
					break;
			# ----------------------------------------------------
			default:
				return '[Invalid element type]';
				break;
			# ----------------------------------------------------
		}
		return $element;
	}
	# -------------------------------------------------------
}
