<?php /*

 Composr
 Copyright (c) Christopher Graham, 2004-2024

 See docs/LICENSE.md for full licensing information.


 NOTE TO PROGRAMMERS:
   Do not edit this file. If you need to make changes, save your changed file to the appropriate *_custom folder
   **** If you ignore this advice, then your website upgrades (e.g. for bug fixes) will likely kill your changes ****

*/

/**
 * @license    http://opensource.org/licenses/cpal_1.0 Common Public Attribution License
 * @copyright  Christopher Graham
 * @package    core_form_interfaces
 */

/**
 * Standard code module initialisation function.
 *
 * @ignore
 */
function init__form_templates()
{
    require_javascript('checking');
    require_javascript('editing');

    global $WYSIWYG_ATTACHED;
    $WYSIWYG_ATTACHED = false;

    global $BLOCK_EXTRA_POSTING_FIELDS;
    $BLOCK_EXTRA_POSTING_FIELDS = false; // Used to signal that there's a main posting form, so we can't allow extra custom posting form fields

    global $TABINDEX;
    $TABINDEX = 50; // Base

    global $NO_DEV_MODE_FULLSTOP_CHECK;
    $NO_DEV_MODE_FULLSTOP_CHECK = false;

    require_code('input_filter');

    global $DOING_ALTERNATE_FIELDS_SET;
    $DOING_ALTERNATE_FIELDS_SET = null;

    // This is a hackerish way to chain raw field inputs together
    global $_FORM_INPUT_PREFIX, $_FORM_INPUT_SUFFIX;
    $_FORM_INPUT_PREFIX = null;
    $_FORM_INPUT_SUFFIX = null;

    global $SKIPPING_LABELS;
    $SKIPPING_LABELS = false;

    // Use pop_field_encapsulation() to return fields wrapped in table rows (the default at runtime).
    if (!defined('FIELD_ENCAPSULATION_ROWS')) {
        define('FIELD_ENCAPSULATION_ROWS', 0);
    }

    // Use push_field_encapsulation(FIELD_ENCAPSULATION_RAW) to return raw fields without being wrapped in table rows.
    if (!defined('FIELD_ENCAPSULATION_RAW')) {
        define('FIELD_ENCAPSULATION_RAW', 1);
    }

    global $FIELD_ENCAPSULATION_STACK;
    $FIELD_ENCAPSULATION_STACK = [FIELD_ENCAPSULATION_ROWS];

    require_css('forms');

    if (function_exists('get_member')) {
        if ((has_privilege(get_member(), 'allow_html')) && (get_value('edit_with_my_comcode_perms') === '1') && ((strpos(get_param_string('type', ''), 'edit') !== false) || (strpos(get_param_string('type', ''), 'edit_category') !== false))) {
            attach_message('You have enabled content editing to assume your permissions via a hidden option. Be VERY careful to check whatever Comcode/HTML you edit.', 'warn');
        }
    }

    if (function_exists('set_no_clickjacking_csp')) {
        set_no_clickjacking_csp();
    }
}

/**
 * Read a multi code from a named parameter stub.
 *
 * @param  ID_TEXT $param The parameter stub (stub of a series of POST parameters, made by cns_get_forum_multi_code_field's field or similar)
 * @return SHORT_TEXT The multi code
 */
function read_multi_code(string $param) : string
{
    $type = post_param_string($param);
    if ($type == '*') {
        return $type;
    }
    if (!array_key_exists($param . '_list', $_POST)) {
        return '';
    }
    $in = implode(',', $_POST[$param . '_list']);
    return $type . $in;
}

/**
 * Ensure Suhosin is not going to break a request due to number of request form fields. Call this each time a field is added to the output.
 *
 * @param  integer $inc How much to increment the counter by
 * @param  integer $name_length The name length being checked
 */
function check_suhosin_request_quantity(int $inc = 1, int $name_length = 0)
{
    static $count = 0;
    $count += $inc;

    static $failed_already = false;
    if ($failed_already) {
        return;
    }

    static $max_values = null;
    if ($max_values === null) {
        $max_values = [];
        foreach (['max_input_vars', 'suhosin.post.max_vars', 'suhosin.request.max_vars'] as $setting) {
            if (@is_numeric(ini_get($setting))) {
                $max_values[$setting] = intval(ini_get($setting));
            }
        }
    }
    foreach ($max_values as $setting => $max_value) {
        if ($max_value < $count) {
            global $MODSECURITY_WORKAROUND_ENABLED;
            if (!$MODSECURITY_WORKAROUND_ENABLED) {
                attach_message(do_lang_tempcode('SUHOSIN_MAX_VARS_TOO_LOW', escape_html($setting)), 'warn', false, true);
            }
            $failed_already = true;
        }
    }

    static $max_length_values = null;
    if ($max_length_values === null) {
        $max_length_values = [];
        foreach (['suhosin.post.max_name_length', 'suhosin.request.max_name_length', 'suhosin.post.max_totalname_length', 'suhosin.request.max_totalname_length'] as $setting) {
            if (@is_numeric(ini_get($setting))) {
                $max_length_values[$setting] = intval(ini_get($setting));
            }
        }
    }
    foreach ($max_length_values as $setting => $max_length_value) {
        if ($max_length_value < $name_length) {
            attach_message(do_lang_tempcode('SUHOSIN_MAX_VARS_TOO_LOW', escape_html($setting)), 'warn', false, true);
            $failed_already = true;
        }
    }
}

/**
 * Ensure Suhosin is not going to break a request due to request size.
 *
 * @param  integer $size Most determinative size within wider request size (we'll assume we actually need 500 more bytes than this)
 */
function check_suhosin_request_size(int $size)
{
    foreach (['suhosin.request.max_value_length', 'suhosin.post.max_value_length'] as $setting) {
        if ((@is_numeric(ini_get($setting))) && (intval(ini_get($setting)) - 500 < $size)) {
            attach_message(do_lang_tempcode('SUHOSIN_MAX_VALUE_TOO_SHORT', escape_html($setting)), 'warn', false, true);
        }
    }
}

/**
 * Enable reading in default parameters from the GET environment. This is typically called before 'add' forms.
 */
function url_default_parameters__enable()
{
    global $URL_DEFAULT_PARAMETERS_ENABLED;
    $URL_DEFAULT_PARAMETERS_ENABLED = true;
}

/**
 * Disable reading in default parameters from the GET environment. This is typically called after 'add' forms.
 */
function url_default_parameters__disable()
{
    global $URL_DEFAULT_PARAMETERS_ENABLED;
    $URL_DEFAULT_PARAMETERS_ENABLED = false;
}

/**
 * Find a default property, defaulting to the average of what is there already, or the given default if really necessary.
 *
 * @param  ?integer $setting The current setting (null: we have to work it out); if non-null, the function will immediately return
 * @param  ID_TEXT $db_property The property
 * @param  ID_TEXT $table The table to average within
 * @param  integer $default The last-resort default
 * @return integer The value
 */
function take_param_int_modeavg(?int $setting, string $db_property, string $table, int $default) : int
{
    if ($setting !== null) {
        return $setting;
    }

    if (running_script('install')) {
        return $default;
    }

    $db = get_db_for($table);
    $val = $db->query_value_if_there('SELECT ' . $db_property . ',count(' . $db_property . ') AS qty FROM ' . $db->get_table_prefix() . $table . ' GROUP BY ' . $db_property . ' ORDER BY qty DESC', false, true); // We need the mode here, not the mean
    if ($val !== null) {
        return $val;
    }

    return $default;
}

/**
 * Attach the WYSIWYG editor.
 */
function attach_wysiwyg()
{
    // Force-disable CSP as CKEditor doesn't yet support CSP
    load_csp(['csp_enabled' => '0']); // TODO: Remove once CKEditor supports CSP (#651)

    global $WYSIWYG_ATTACHED;
    if (!$WYSIWYG_ATTACHED) {
        require_code('site');
        attach_to_javascript(do_template('WYSIWYG_LOAD'));
    }
    $WYSIWYG_ATTACHED = true;
}

/**
 * Insert hidden data for the maximum file size of form fields.
 *
 * @param  Tempcode $hidden Hidden fields
 * @param  ID_TEXT $regular_max_size_type Code representing the media types we are using limits for
 * @set image file
 */
function handle_max_file_size(object &$hidden, string $regular_max_size_type = 'file')
{
    require_code('files2');
    switch ($regular_max_size_type) {
        case 'image':
            require_code('images');
            $regular_max_size = get_max_image_size();
            break;
        case 'file':
        default:
            $regular_max_size = get_max_file_size();
            break;
    }
    $hidden->attach(form_input_hidden('MAX_FILE_SIZE', strval($regular_max_size)));
}

/**
 * Get what we need to get attachments in a form-field interface.
 *
 * @param  ID_TEXT $posting_field_name The name of the field attachments are for
 * @param  boolean $true_attachment_ui Include a true attachment UI (as opposed to something say on a contact form)
 * @return array A pair: the attachments UI (Tempcode), the hidden attachment field
 */
function get_attachments(string $posting_field_name, bool $true_attachment_ui = true) : array
{
    $image_types = str_replace(',', ', ', get_option('valid_images'));

    require_lang('comcode');
    require_javascript('plupload');
    require_css('widget_plupload');
    require_javascript('editing');
    require_javascript('checking');
    require_javascript('posting');

    require_code('upload_syndication');
    list($syndication_json, $filter) = get_upload_syndication_json(CMS_UPLOAD_ANYTHING);

    if (get_forum_type() == 'cns') {
        require_code('cns_groups');
        require_lang('cns');
        $max_attachments = cns_get_member_best_group_property(get_member(), 'max_attachments_per_post');
    } else {
        $max_attachments = 100;
    }
    if ($max_attachments == 0) {
        return [new Tempcode(), new Tempcode()];
    }

    require_code('files2');
    $max_attach_size = get_max_file_size(($syndication_json === null) ? get_member() : null, $GLOBALS['SITE_DB']);
    $no_quota = ((get_forum_type() == 'cns') && (cns_get_member_best_group_property(get_member(), 'max_daily_upload_mb') == 0));
    if ($no_quota) {
        if ($syndication_json === null) {
            return [new Tempcode(), new Tempcode()];
        }
    } else {
        $filter = null;
    }
    $attach_size_field = form_input_hidden('MAX_FILE_SIZE', strval($max_attach_size));

    $num_attachments = post_param_integer('num_attachments', 1);

    $attachments = new Tempcode();
    for ($i = 1; $i <= $num_attachments; $i++) {
        $attachments->attach(do_template('ATTACHMENT', [
            '_GUID' => 'c3b38ca70cbd1c5f9cf91bcae9ed1134',
            'POSTING_FIELD_NAME' => $posting_field_name,
            'I' => strval($i),
            'SYNDICATION_JSON' => $syndication_json,
            'NO_QUOTA' => $no_quota,
            'FILTER' => $filter,
            'TRUE_ATTACHMENT_UI' => $true_attachment_ui,
        ]));
    }

    $attachment_template = do_template('ATTACHMENT', [
        '_GUID' => 'c3b38ca70cbd1c5f9cf91bcae9ed11ds',
        'POSTING_FIELD_NAME' => $posting_field_name,
        'I' => '__num_attachments__',
        'SYNDICATION_JSON' => $syndication_json,
        'NO_QUOTA' => $no_quota,
        'FILTER' => $filter,
        'TRUE_ATTACHMENT_UI' => $true_attachment_ui,
    ]);
    $attachments = do_template('ATTACHMENTS', [
        '_GUID' => '054921e7c09412be479676759accf222',
        'POSTING_FIELD_NAME' => $posting_field_name,
        'ATTACHMENT_TEMPLATE' => $attachment_template,
        'IMAGE_TYPES' => $image_types,
        'ATTACHMENTS' => $attachments,
        'MAX_ATTACHMENTS' => strval($max_attachments),
        'NUM_ATTACHMENTS' => strval($num_attachments),
        'TRUE_ATTACHMENT_UI' => $true_attachment_ui,
        'FILTER' => $filter,
    ]);

    return [$attachments, $attach_size_field];
}

/**
 * Creates a posting form, with attachment support.
 *
 * @param  mixed $submit_name The title of the form submission button, provided in plain-text format (string or Tempcode)
 * @param  ID_TEXT $submit_icon The submit icon CSS class
 * @param  LONG_TEXT $post The default post to put in
 * @param  mixed $post_url Where the form is sent (URLPATH or Tempcode)
 * @param  Tempcode $hidden_fields A form_input_hidden buildup of hidden fields (additional parameters sent to the target URL)
 * @param  Tempcode $specialisation A buildup of leading extra fields, in a format compatible with the templates used by this function
 * @param  ?mixed $post_comment The post comment (string or Tempcode). This gives information about how you should post. (null: no post comment)
 * @param  string $extra Extra info to put on the posting form
 * @param  ?Tempcode $specialisation2 A buildup of trailing extra fields, in a format compatible with the templates used by this function. (null: none)
 * @param  ?Tempcode $default_parsed The parsed Comcode. (null: calculate)
 * @param  array $js_function_calls JavaScript functions to execute
 * @param  ?integer $tabindex The tab index of the field (null: not specified)
 * @param  boolean $required Whether this is a required input field
 * @param  boolean $has_preview Whether the form supports previewing
 * @param  boolean $support_wysiwyg Whether to support WYSIWYG mode
 * @param  boolean $support_autosave Whether to support auto-save
 * @param  boolean $specialisation2_hidden Whether to hide trailing extra fields
 * @param  mixed $description A description for this input field, provided in HTML format (string or Tempcode)
 * @param  ?Tempcode $cancel_url Cancel URL for cancelling auto-save (null: no cancel button)
 * @param  ?mixed $staff_help_url URL staff can go to to get help (null: none)
 * @return Tempcode The posting form
 */
function get_posting_form($submit_name, string $submit_icon, string $post, $post_url, object $hidden_fields, object $specialisation, $post_comment = null, string $extra = '', ?object $specialisation2 = null, ?object $default_parsed = null, array $js_function_calls = [], ?int $tabindex = null, bool $required = true, bool $has_preview = true, bool $support_wysiwyg = true, bool $support_autosave = true, bool $specialisation2_hidden = false, $description = '', ?object $cancel_url = null, $staff_help_url = null) : object
{
    require_javascript('posting');
    require_javascript('plupload');
    require_css('widget_plupload');

    require_lang('comcode');

    handle_default_comcode_text($post);

    $tabindex = get_form_field_tabindex($tabindex);

    $post = filter_form_field_default(is_object($submit_name) ? $submit_name->evaluate() : $submit_name, $post);

    require_code('comcode_cleanup');
    $comcode_page_hints = comcode_page_hints_ui('post', $post);

    $required = filter_form_field_required('post', $required);

    check_suhosin_request_size(strlen($post));

    if ($post_comment === null) {
        $post_comment = do_lang_tempcode('POST_COMMENT');
    }
    if ($specialisation2 === null) {
        $specialisation2 = new Tempcode();
    }

    list($attachments, $attach_size_field) = get_attachments('post');

    $hidden_fields->attach($attach_size_field);

    $help_zone = get_comcode_zone('userguide_comcode', false);

    $emoticon_chooser = $GLOBALS['FORUM_DRIVER']->get_emoticon_chooser();

    $comcode_editor = get_comcode_editor('post', false, true);
    $comcode_editor_small = get_comcode_editor('post', true, true);

    $w = ($support_wysiwyg) && (browser_matches('wysiwyg', $post) && (strpos($post, '{$,page hint: no_wysiwyg}') === false));

    $class = '';
    attach_wysiwyg();
    if ($w) {
        $class .= ' wysiwyg';
    }

    push_lax_comcode(true);
    $GLOBALS['COMCODE_PARSE_URLS_CHECKED'] = 100; // FUDGE: Little hack to stop it checking any URLs
    /*Actually we reparse always to ensure it is done in semiparse mode if ($default_parsed === null) */
    $default_parsed = @comcode_to_tempcode($post, null, false, null, null, COMCODE_SEMIPARSE_MODE);
    pop_lax_comcode();

    global $MODSECURITY_WORKAROUND_ENABLED;

    return do_template('POSTING_FORM', [
        '_GUID' => '41259424ca13c437d5bc523ce18980fe',
        'REQUIRED' => $required,
        'TABINDEX_PF' => strval($tabindex)/*not called TABINDEX due to conflict with FORM_STANDARD_END*/,
        'JS_FUNCTION_CALLS' => $js_function_calls,
        'PREVIEW' => $has_preview,
        'COMCODE_EDITOR' => $comcode_editor,
        'COMCODE_EDITOR_SMALL' => $comcode_editor_small,
        'CLASS' => $class,
        'COMCODE_URL' => ($help_zone === null) ? new Tempcode() : build_url(['page' => 'userguide_comcode'], $help_zone),
        'EXTRA' => $extra,
        'POST_COMMENT' => $post_comment,
        'EMOTICON_CHOOSER' => $emoticon_chooser,
        'SUBMIT_ICON' => $submit_icon,
        'SUBMIT_NAME' => $submit_name,
        'HIDDEN_FIELDS' => $hidden_fields,
        'URL' => $post_url,
        'POST' => $post,
        'DEFAULT_PARSED' => $default_parsed,
        'ATTACHMENTS' => $attachments,
        'SPECIALISATION' => $specialisation,
        'SPECIALISATION2' => $specialisation2,
        'SPECIALISATION2_HIDDEN' => $specialisation2_hidden,
        'SUPPORT_AUTOSAVE' => $support_autosave,
        'CANCEL_URL' => $cancel_url,
        'DESCRIPTION' => $description,
        'MODSECURITY_WORKAROUND' => $MODSECURITY_WORKAROUND_ENABLED,
        'COMCODE_PAGE_HINTS' => $comcode_page_hints,
        'STAFF_HELP_URL' => $staff_help_url,
    ]);
}

/**
 * Creates a Comcode editor.
 *
 * @param  string $field_name The name of the field the editor is working for
 * @param  boolean $cut_down Whether to make a cut-down version
 * @param  boolean $is_posting_field Whether this is for a posting field (i.e. has attachment support)
 * @return Tempcode The Comcode editor
 */
function get_comcode_editor(string $field_name = 'post', bool $cut_down = false, bool $is_posting_field = false) : object
{
    require_lang('comcode');

    $buttons = new Tempcode();
    $_buttons = [];

    // Non-wrappers
    if (!$cut_down) {
        $_buttons['thumb'] = do_lang_tempcode('ADD_COMCODE_IMAGE');
    }
    if (has_privilege(get_member(), 'comcode_dangerous')) {
        $_buttons['block'] = do_lang_tempcode('ADD_BLOCK');
    }
    $_buttons['comcode'] = do_lang_tempcode('COMCODE_ADD_TAG');
    if (!$cut_down) {
        $_buttons['list'] = do_lang_tempcode('LIST'); // NB: list isn't actually a Comcode tag, it's a textcode syntax
    }

    // Links
    if (!$cut_down) {
        $_buttons['url'] = do_lang_tempcode('ADD_LINK');
    }
    if (has_zone_access(get_member(), 'adminzone')) {
        $_buttons['page'] = do_lang_tempcode('PAGE');
    }
    //if (!$cut_down) $_buttons['email'] = do_lang_tempcode('config:_EMAIL');   Not enough space anymore

    // Wrappers
    $_buttons['quote'] = do_lang_tempcode('QUOTE');
    if ((get_option('simplify_wysiwyg_by_permissions') == '0') || (has_privilege(get_member(), 'allow_html'))) {
        $_buttons['box'] = do_lang_tempcode('ADD_BOX'); // This leads to precisionEditing being set in _wysiwyg_settings.js
    }
    $_buttons['code'] = do_lang_tempcode('CODE');
    if (has_privilege(get_member(), 'allow_html')) {
        if (!$cut_down) {
            $_buttons['html'] = do_lang_tempcode('HTML');
        }
    }
    $i = 0;
    foreach ($_buttons as $button => $label) {
        $divider = false;
        if (($button == 'url') || ($button == 'quote') || ($i == 0)) {
            $divider = true;
        }
        $buttons->attach(do_template('COMCODE_EDITOR_BUTTON', [
            '_GUID' => 'e4fe3bc16cec070e06532fedc598d075',
            'DIVIDER' => $divider,
            'FIELD_NAME' => $field_name,
            'LABEL' => $label,
            'TITLE' => do_lang_tempcode('INPUT_COMCODE_' . $button),
            'B' => $button,
            'IS_POSTING_FIELD' => $is_posting_field,
        ]));
        $i++;
    }

    $micro_buttons = new Tempcode();
    if (!$cut_down) {
        $_micro_buttons = [
            ['t' => 'b'],
            ['t' => 'i'],
        ];
        foreach ($_micro_buttons as $button) {
            $micro_buttons->attach(do_template('COMCODE_EDITOR_MICRO_BUTTON', [
                '_GUID' => 'dbab001b3fa5480bb590ffed3ca81eaf',
                'FIELD_NAME' => $field_name,
                'TITLE' => do_lang_tempcode('INPUT_COMCODE_' . $button['t']),
                'B' => $button['t'],
                'IS_POSTING_FIELD' => $is_posting_field,
            ]));
        }
    }
    return do_template('COMCODE_EDITOR', [
        '_GUID' => 'ebff3145776a0441d115f2e4e13617d6',
        'POSTING_FIELD' => $field_name,
        'BUTTONS' => $buttons,
        'MICRO_BUTTONS' => $micro_buttons,
        'IS_POSTING_FIELD' => $is_posting_field,
    ]);
}

/**
 * Find whether WYSIWYG is currently on.
 *
 * @param  ?string $default Comcode that might be WYSIWYG edited (null: none)
 * @return boolean Whether it is
 */
function wysiwyg_on(?string $default = null) : bool
{
    return ((browser_matches('wysiwyg', $default)) && ((!array_key_exists('use_wysiwyg', $_COOKIE)) || ($_COOKIE['use_wysiwyg'] != '0')));
}

/**
 * Find if a form field is required via fields.xml filters.
 *
 * @param  ID_TEXT $name The codename for this field
 * @param  boolean $required Whether it is required by default
 * @return boolean Whether it is required
 */
function filter_form_field_required(string $name, bool $required) : bool
{
    if (!$required) {
        $minlength = get_field_restrict_property('minlength', $name);
        if ((!empty($minlength)) && (intval($minlength) > 0)) {
            $required = true;
        }
    }
    return $required;
}

/**
 * Get the value of a scoped field restriction property. Returns "first-found".
 *
 * @param  string $property The name of the property
 * @param  string $field The name of the field it's scoped for
 * @param  ?string $page The page name scoped for (null: current page)
 * @param  ?string $type The page type scoped for (null: current type)
 * @return ?string The property (null: non-existent)
 */
function get_field_restrict_property(string $property, string $field, ?string $page = null, ?string $type = null) : ?string
{
    if ($page === null) {
        $page = get_page_name();
    }
    if ($type === null) {
        $type = get_param_string('type', post_param_string('type', 'browse'));
    }

    $restrictions = load_field_restrictions($page, $type);
    foreach ($restrictions as $_r => $_restrictions) {
        $_r_exp = explode(',', $_r);
        foreach ($_r_exp as $__r) {
            if (simulated_wildcard_match($field, trim($__r), true)) {
                foreach ($_restrictions as $bits) {
                    list($restriction, $attributes) = $bits;
                    if (cms_strtolower_ascii($restriction) == cms_strtolower_ascii($property)) {
                        return $bits[1]['embed'];
                    }
                }
            }
        }
    }
    return null;
}

/**
 * Get the Tempcode for a codename input line.
 *
 * @param  mixed $pretty_name A human intelligible name for this input field, provided in plain-text format (string or Tempcode)
 * @param  mixed $description A description for this input field, provided in HTML format (string or Tempcode)
 * @param  ID_TEXT $name The name which this input field is for
 * @param  ?string $default The default value for this input field (null: blank)
 * @param  boolean $required Whether this is a required input field
 * @param  ?integer $tabindex The tab index of the field (null: not specified)
 * @param  ?integer $_maxlength The maximum length of the field (null: default 80)
 * @param  array $extra_chars List of extra characters to allow
 * @param  ?string $placeholder The placeholder value for this input field (null: none)
 * @param  ~?mixed $autocomplete The autocomplete field name. (false: explicitly disable autocomplete) (null: no autocomplete attribute unless there's a default for this $name)
 * @return Tempcode The input field
 */
function form_input_codename($pretty_name, $description, string $name, ?string $default, bool $required, ?int $tabindex = null, ?int $_maxlength = 40, array $extra_chars = [], ?string $placeholder = null, $autocomplete = null) : object
{
    if ($default === null) {
        $default = '';
    }

    $default = filter_form_field_default($name, $default);
    $required = filter_form_field_required($name, $required);

    $autocomplete = _get_autocomplete_attribute_value($name, $autocomplete);

    $tabindex = get_form_field_tabindex($tabindex);

    $_required = ($required) ? '-required' : '';
    $maxlength = get_field_restrict_property('maxlength', $name);
    if (($maxlength === null) && ($_maxlength !== null)) {
        $maxlength = strval($_maxlength);
    }
    $input = do_template('FORM_SCREEN_INPUT_CODENAME', [
        '_GUID' => '4b1a3a3ebe6ac85c7c14bcec9d8ab88d',
        'PLACEHOLDER' => $placeholder,
        'MAXLENGTH' => $maxlength,
        'TABINDEX' => strval($tabindex),
        'REQUIRED' => $_required,
        'NAME' => $name,
        'DEFAULT' => $default,
        'EXTRA_CHARS' => $extra_chars,
        'AUTOCOMPLETE' => $autocomplete,
    ]);
    return _form_input($name, $pretty_name, $description, $input, $required, false, $tabindex);
}

/**
 * Get the Tempcode for a text input line (an <input type="text" ... />).
 *
 * @param  mixed $pretty_name A human intelligible name for this input field, provided in plain-text format (string or Tempcode)
 * @param  mixed $description A description for this input field, provided in HTML format (string or Tempcode)
 * @param  ID_TEXT $name The name which this input field is for
 * @param  ?string $default The default value for this input field (null: blank)
 * @param  boolean $required Whether this is a required input field
 * @param  ?integer $tabindex The tab index of the field (null: not specified)
 * @param  ?integer $_maxlength The maximum length of the field (null: default 255)
 * @param  string $type The input type
 * @param  ?string $placeholder The placeholder value for this input field (null: none)
 * @param  ?string $pattern Custom regex pattern, covers whole field value (null: none)
 * @param  ?string $pattern_error Custom regex pattern validation error (null: none)
 * @param  ?integer $size How much space the list takes up (inline lists only) (null: default 27)
 * @param  ~?mixed $autocomplete The autocomplete field name. (false: explicitly disable autocomplete) (null: no autocomplete attribute unless there's a default for this $name)
 * @return Tempcode The input field
 */
function form_input_line($pretty_name, $description, string $name, ?string $default, bool $required, ?int $tabindex = null, ?int $_maxlength = null, string $type = 'text', ?string $placeholder = null, ?string $pattern = null, ?string $pattern_error = null, ?int $size = null, $autocomplete = null) : object
{
    if ($default === null) {
        $default = '';
    }

    $default = filter_form_field_default($name, $default);
    $required = filter_form_field_required($name, $required);

    $autocomplete = _get_autocomplete_attribute_value($name, $autocomplete);

    $tabindex = get_form_field_tabindex($tabindex);

    $_required = ($required) ? '-required' : '';
    $maxlength = get_field_restrict_property('maxlength', $name);
    if (($maxlength === null) && ($_maxlength !== null)) {
        $maxlength = strval($_maxlength);
    }

    if ($size === null) {
        $size = 27;
    }

    $input = do_template('FORM_SCREEN_INPUT_LINE', [
        '_GUID' => '02789c9af25cbc971e86bfcc0ad322d5',
        'PLACEHOLDER' => $placeholder,
        'MAXLENGTH' => $maxlength,
        'TABINDEX' => strval($tabindex),
        'REQUIRED' => $_required,
        'NAME' => $name,
        'DEFAULT' => $default,
        'TYPE' => $type,
        'PATTERN' => $pattern,
        'SIZE' => strval($size),
        'AUTOCOMPLETE' => $autocomplete,
    ]);
    return _form_input($name, $pretty_name, $description, $input, $required, false, $tabindex, false, false, '', (($pattern_error === null) && ($pattern !== null)) ? strip_html($description->evaluate()) : $pattern_error);
}

/**
 * Get the Tempcode for a URL.
 *
 * @param  mixed $pretty_name A human intelligible name for this input field, provided in plain-text format (string or Tempcode)
 * @param  mixed $description A description for this input field, provided in HTML format (string or Tempcode)
 * @param  ID_TEXT $name The name which this input field is for
 * @param  ?string $default The default value for this input field (null: blank)
 * @param  boolean $required Whether this is a required input field
 * @param  ?integer $tabindex The tab index of the field (null: not specified)
 * @param  ~?mixed $autocomplete The autocomplete field name. (false: explicitly disable autocomplete) (null: no autocomplete attribute unless there's a default for this $name)
 * @return Tempcode The input field
 */
function form_input_url($pretty_name, $description, string $name, ?string $default, bool $required, ?int $tabindex = null, $autocomplete = null) : object
{
    if ($default === null) {
        $default = '';
    }

    $default = filter_form_field_default($name, $default);
    $required = filter_form_field_required($name, $required);

    require_code('urls_simplifier');
    $coder_ob = new HarmlessURLCoder();
    $_default = ($default === null) ? '' : $coder_ob->decode($default);

    $autocomplete = _get_autocomplete_attribute_value($name, $autocomplete);

    $tabindex = get_form_field_tabindex($tabindex);

    $_required = ($required) ? '-required' : '';
    $input = do_template('FORM_SCREEN_INPUT_URL', [
        '_GUID' => '12789c9af25cbc971e86bfcc0ad322d5',
        'TABINDEX' => strval($tabindex),
        'REQUIRED' => $_required,
        'NAME' => $name,
        'DEFAULT' => $_default,
        'AUTOCOMPLETE' => $autocomplete,
    ]);
    return _form_input($name, $pretty_name, $description, $input, $required, false, $tabindex);
}

/**
 * Get the Tempcode for a username input line.
 *
 * @param  mixed $pretty_name A human intelligible name for this input field, provided in plain-text format (string or Tempcode)
 * @param  mixed $description A description for this input field, provided in HTML format (string or Tempcode)
 * @param  ID_TEXT $name The name which this input field is for
 * @param  ?string $default The default value for this input field (null: blank)
 * @param  boolean $required Whether this is a required input field
 * @param  boolean $needs_match Whether it is required than a valid username is given
 * @param  ?integer $tabindex The tab index of the field (null: not specified)
 * @param  ~?mixed $autocomplete The autocomplete field name. (false: explicitly disable autocomplete) (null: no autocomplete attribute unless there's a default for this $name)
 * @return Tempcode The input field
 */
function form_input_username($pretty_name, $description, string $name, ?string $default, bool $required, bool $needs_match = true, ?int $tabindex = null, $autocomplete = null) : object
{
    if ($default === null) {
        $default = '';
    }

    $default = filter_form_field_default($name, $default);
    $required = filter_form_field_required($name, $required);

    require_javascript('ajax_people_lists');

    $autocomplete = _get_autocomplete_attribute_value($name, $autocomplete);

    $tabindex = get_form_field_tabindex($tabindex);

    $_required = ($required) ? '-required' : '';
    $input = do_template('FORM_SCREEN_INPUT_USERNAME', [
        '_GUID' => '591b5fe23f0cc0a4975a52d52aa5701e',
        'TABINDEX' => strval($tabindex),
        'NEEDS_MATCH' => $needs_match,
        'REQUIRED' => $_required,
        'NAME' => $name,
        'DEFAULT' => $default,
        'AUTOCOMPLETE' => $autocomplete,
    ]);
    return _form_input($name, $pretty_name, $description, $input, $required, false, $tabindex);
}

/**
 * Get the Tempcode for a author/username input line.
 *
 * @param  mixed $pretty_name A human intelligible name for this input field, provided in plain-text format (string or Tempcode)
 * @param  mixed $description A description for this input field, provided in HTML format (string or Tempcode)
 * @param  ID_TEXT $name The name which this input field is for
 * @param  ?string $default The default value for this input field (null: blank)
 * @param  boolean $required Whether this is a required input field
 * @param  ?integer $tabindex The tab index of the field (null: not specified)
 * @param  ~?mixed $autocomplete The autocomplete field name. (false: explicitly disable autocomplete) (null: no autocomplete attribute unless there's a default for this $name)
 * @return Tempcode The input field
 */
function form_input_author($pretty_name, $description, string $name, ?string $default, bool $required, ?int $tabindex = null, $autocomplete = null) : object
{
    if (!addon_installed('authors')) {
        return form_input_username($pretty_name, $description, $name, $default, $required, true, $tabindex, $autocomplete);
    }

    if ($default === null) {
        $default = '';
    }

    $default = filter_form_field_default($name, $default);
    $required = filter_form_field_required($name, $required);

    require_javascript('ajax_people_lists');

    $autocomplete = _get_autocomplete_attribute_value($name, $autocomplete);

    $tabindex = get_form_field_tabindex($tabindex);

    $_description = new Tempcode();
    $_description->attach($description);
    if (!$_description->is_empty()) {
        $_description->attach(do_template('FORM_DESCRIP_SEP'));
    }
    $keep = symbol_tempcode('KEEP');
    $extra = do_template('HYPERLINK_OVERLAY', [
        '_GUID' => 'fb25dc4777a166c143a1bc32ff0c3239',
        'URL' => find_script('authors') . '?field_name=' . urlencode($name) . $keep->evaluate(),
        'TITLE' => do_lang_tempcode('AUTHOR'),
        'CAPTION' => do_lang_tempcode('BROWSE_SENTENCE'),
    ]);
    $_description->attach($extra);

    $_required = ($required) ? '-required' : '';
    $input = do_template('FORM_SCREEN_INPUT_AUTHOR', [
        '_GUID' => '2662a51e494120078b4022915593e28a',
        'TABINDEX' => strval($tabindex),
        'REQUIRED' => $_required,
        'NAME' => $name,
        'DEFAULT' => $default,
        'AUTOCOMPLETE' => $autocomplete,
    ]);
    return _form_input($name, $pretty_name, $_description, $input, $required, false, $tabindex);
}

/**
 * Get the Tempcode for a e-mail-address input line.
 *
 * @param  mixed $pretty_name A human intelligible name for this input field, provided in plain-text format (string or Tempcode)
 * @param  mixed $description A description for this input field, provided in HTML format (string or Tempcode)
 * @param  ID_TEXT $name The name which this input field is for
 * @param  ?string $default The default value for this input field (null: blank)
 * @param  boolean $required Whether this is a required input field
 * @param  ?integer $tabindex The tab index of the field (null: not specified)
 * @param  ~?mixed $autocomplete The autocomplete field name. (false: explicitly disable autocomplete) (null: no autocomplete attribute unless there's a default for this $name)
 * @return Tempcode The input field
 */
function form_input_email($pretty_name, $description, string $name, ?string $default, bool $required, ?int $tabindex = null, $autocomplete = null) : object
{
    if ($default === null) {
        $default = '';
    }

    $default = filter_form_field_default($name, $default);
    $required = filter_form_field_required($name, $required);

    $autocomplete = _get_autocomplete_attribute_value($name, $autocomplete);

    $tabindex = get_form_field_tabindex($tabindex);

    $_required = ($required) ? '-required' : '';
    $input = do_template('FORM_SCREEN_INPUT_EMAIL', [
        '_GUID' => '2ff1d9e21894710b8f09598fd92049c7',
        'TABINDEX' => strval($tabindex),
        'REQUIRED' => $_required,
        'NAME' => $name,
        'DEFAULT' => $default,
        'AUTOCOMPLETE' => $autocomplete,
    ]);
    return _form_input($name, $pretty_name, $description, $input, $required, false, $tabindex);
}

/**
 * Get the Tempcode for a colour input.
 *
 * @param  mixed $pretty_name A human intelligible name for this input field, provided in plain-text format (string or Tempcode)
 * @param  mixed $description A description for this input field, provided in HTML format (string or Tempcode)
 * @param  ID_TEXT $name The name which this input field is for
 * @param  ?string $default The default value for this input field (null: blank)
 * @param  boolean $required Whether this is a required input field
 * @param  ?integer $tabindex The tab index of the field (null: not specified)
 * @return Tempcode The input field
 */
function form_input_colour($pretty_name, $description, string $name, ?string $default, bool $required, ?int $tabindex = null) : object
{
    if ($default === null) {
        $default = '';
    }

    if ($default == 'inherit') {
        $default = '';
    }

    $default = filter_form_field_default($name, $default);
    $required = filter_form_field_required($name, $required);

    $tabindex = get_form_field_tabindex($tabindex);

    $_required = ($required) ? '-required' : '';
    return do_template('FORM_SCREEN_INPUT_COLOUR', [
        '_GUID' => '9a1a8061cebd717ea98522984d9465af',
        'RAW_FIELD' => false,
        'REQUIRED' => $required,
        'PRETTY_NAME' => $pretty_name,
        'DESCRIPTION' => $description,
        'TABINDEX' => strval($tabindex),
        '_REQUIRED' => $_required,
        'NAME' => $name,
        'DEFAULT' => $default,
    ]);
}

/**
 * Get the Tempcode for a page-link input.
 *
 * @param  mixed $pretty_name A human intelligible name for this input field, provided in plain-text format (string or Tempcode)
 * @param  mixed $description A description for this input field, provided in HTML format (string or Tempcode)
 * @param  ID_TEXT $name The name which this input field is for
 * @param  ?string $default The default value for this input field (null: blank)
 * @param  boolean $required Whether this is a required input field
 * @param  ?integer $tabindex The tab index of the field (null: not specified)
 * @param  ?ID_TEXT $page_type Page type to show (null: all)
 * @param  boolean $get_title_too Whether to also get the title for the page
 * @return Tempcode The input field
 */
function form_input_page_link($pretty_name, $description, string $name, ?string $default, bool $required, ?int $tabindex = null, ?string $page_type = null, bool $get_title_too = false) : object
{
    require_lang('menus');

    require_javascript('tree_list');

    // Display
    $input = do_template('PAGE_LINK_CHOOSER', [
        '_GUID' => 'aabbd8e80df919afe08ca70bd24578dc',
        'AS_FIELD' => true,
        'GET_TITLE_TOO' => $get_title_too,
        'NAME' => $name,
        'VALUE' => $default,
        'PAGE_TYPE' => $page_type,
    ]);

    return _form_input($name, $pretty_name, $description, $input, $required, false, $tabindex, false, true);
}

/**
 * Get the Tempcode for a Comcode-enabled text input line.
 *
 * @param  mixed $pretty_name A human intelligible name for this input field, provided in plain-text format (string or Tempcode)
 * @param  mixed $description A description for this input field, provided in HTML format (string or Tempcode)
 * @param  ID_TEXT $name The name which this input field is for
 * @param  ?string $default The default value for this input field (null: blank)
 * @param  boolean $required Whether this is a required input field
 * @param  ?integer $tabindex The tab index of the field (null: not specified)
 * @param  ~?mixed $autocomplete The autocomplete field name. (false: explicitly disable autocomplete) (null: no autocomplete attribute unless there's a default for this $name)
 * @return Tempcode The input field
 */
function form_input_line_comcode($pretty_name, $description, string $name, ?string $default, bool $required, ?int $tabindex = null, $autocomplete = null) : object
{
    require_lang('comcode');

    if ($default === null) {
        $default = '';
    }

    $default = filter_form_field_default($name, $default);
    $required = filter_form_field_required($name, $required);

    $autocomplete = _get_autocomplete_attribute_value($name, $autocomplete);

    $tabindex = get_form_field_tabindex($tabindex);

    $_required = ($required) ? '-required' : '';
    $input = do_template('FORM_SCREEN_INPUT_LINE', [
        '_GUID' => 'b47034df1d68c1465d045fca822071a1',
        'MAXLENGTH' => get_field_restrict_property('maxlength', $name),
        'TABINDEX' => strval($tabindex),
        'REQUIRED' => $_required,
        'NAME' => $name,
        'DEFAULT' => $default,
        'SIZE' => '40',
        'AUTOCOMPLETE' => $autocomplete,
    ]);
    return _form_input($name, $pretty_name, $description, $input, $required, true, $tabindex);
}

/**
 * Get the Tempcode for a DHTML input field that takes multiple lines. A new line is added when the prior one isn't blank.
 *
 * @param  mixed $pretty_name A human intelligible name for this input field, provided in plain-text format (string or Tempcode)
 * @param  mixed $description A description for this input field, provided in HTML format (string or Tempcode)
 * @param  ID_TEXT $name The base parameter name which this input field is for (as this takes multiple parameters, they are named <name><x>). This name must end with '_'.
 * @param  array $_default_array An array or map of lines to use as default (at least this many lines, filled by this array, will be presented by default) (map format: [name=>string, readonly=>boolean])
 * @param  integer $num_required The minimum number of inputs allowed
 * @param  ?integer $tabindex The tab index of the field (null: not specified)
 * @param  string $class CSS class for input
 * @set line email
 * @param  ?string $pattern Custom regex pattern, covers whole field value (null: none)
 * @param  ?string $pattern_error Custom regex pattern validation error (null: none)
 * @param  boolean $confined Only items in $_default_array are allowed
 * @return Tempcode The input field
 */
function form_input_line_multi($pretty_name, $description, string $name, array $_default_array, int $num_required, ?int $tabindex = null, string $class = 'line', ?string $pattern = null, ?string $pattern_error = null, bool $confined = false) : object
{
    if (substr($name, -1) != '_' && substr($name, -2) != '[]') {
        $name .= '_';
    }

    if ($num_required == 0) {
        $required = filter_form_field_required($name, false);
        if ($required) {
            $num_required = 1;
        }
    }

    $tabindex = get_form_field_tabindex($tabindex);
    $default_array = [];

    // Convert answers to a map.
    $i = 0;
    foreach ($_default_array as $default) {
        $_required = ($i < $num_required) ? '-required' : '';
        if (is_array($default) && array_key_exists('name', $default)) {
            $default_array[] = [
                'NAME' => $default['name'],
                'I' => strval($i),
                'REQUIRED' => $_required,
                'READONLY' => array_key_exists('readonly', $default)
            ];
        } else {
            $default_array[] = [
                'NAME' => $default,
                'I' => strval($i),
                'REQUIRED' => $_required,
                'READONLY' => false
            ];
        }
        $i++;
    }

    // Add in blank / initial lines where applicable
    $num_to_show_initially = max($num_required, count($default_array) + 1);
    for (; $i < $num_to_show_initially; $i++) {
        $default_array[] = [
            'NAME' => ($i === 0) ? filter_form_field_default($name, '') : '',
            'I' => strval($i),
            'REQUIRED' => ($i >= $num_required) ? '' : '-required',
            'READONLY' => false
        ];
        $i++;
    }

    // Generate form
    $input = new Tempcode();
    $input->attach(do_template('FORM_SCREEN_INPUT_LINE_MULTI', [
        '_GUID' => 'e2da34b7564cebfd83da2859e4abd020',
        'CLASS' => $class,
        'MAXLENGTH' => get_field_restrict_property('maxlength', $name),
        'PRETTY_NAME' => $pretty_name,
        'TABINDEX' => strval($tabindex),
        'NAME_STUB' => $name,
        'DEFAULT_ARRAY' => $default_array,
        'PATTERN' => $pattern,
        'NUM_REQUIRED' => strval($num_required),
    ]));

    return _form_input(preg_replace('#\[\]$#', '', $name), $pretty_name, $description, $input, $num_required > 0, false, $tabindex, false, true, '', $pattern_error);
}

/**
 * Get the Tempcode for a DHTML input field that takes multiple textareas. A new textarea is added when the prior one isn't blank.
 *
 * @param  mixed $pretty_name A human intelligible name for this input field, provided in plain-text format (string or Tempcode)
 * @param  mixed $description A description for this input field, provided in HTML format (string or Tempcode)
 * @param  ID_TEXT $name The base parameter name which this input field is for (as this takes multiple parameters, they are named <name><x>). This name must end with '_'.
 * @param  array $default_array An array of texts to use as default (at least this many textareas, filled by this array, will be presented by default)
 * @param  integer $num_required The minimum number of textareas allowed
 * @param  ?integer $tabindex The tab index of the field (null: not specified)
 * @param  ?integer $maxlength The maximum length of the field (null: unlimited)
 * @return Tempcode The input field
 */
function form_input_text_multi($pretty_name, $description, string $name, array $default_array, int $num_required, ?int $tabindex = null, ?int $maxlength = null) : object
{
    if (substr($name, -1) != '_') {
        $name .= '_';
    }

    $tabindex = get_form_field_tabindex($tabindex);

    $default_array[0] = filter_form_field_default($name, array_key_exists(0, $default_array) ? $default_array[0] : '');
    if ($num_required == 0) {
        $required = filter_form_field_required($name, false);
        if ($required) {
            $num_required = 1;
        }
    }

    $input = new Tempcode();
    $i = 0;
    foreach ($default_array as $default) {
        $_required = ($i < $num_required) ? '-required' : '';
        $input->attach(do_template('FORM_SCREEN_INPUT_TEXT_MULTI', [
            '_GUID' => '0d9e3c073d09d1ce3725f47813375c28',
            'PRETTY_NAME' => $pretty_name,
            'TABINDEX' => strval($tabindex),
            'NAME_STUB' => $name,
            'I' => strval($i),
            'REQUIRED' => $_required,
            'DEFAULT' => $default,
            'MAXLENGTH' => ($maxlength === null) ? null : strval($maxlength),
        ]));
        $i++;
    }
    for (; $i < $num_required; $i++) {
        $input->attach(do_template('FORM_SCREEN_INPUT_TEXT_MULTI', [
            '_GUID' => '2e816a71ef5a9ac9e1aac4bd1c13b5bd',
            'PRETTY_NAME' => $pretty_name,
            'TABINDEX' => strval($tabindex),
            'NAME_STUB' => $name,
            'I' => strval($i),
            'REQUIRED' => '-required',
            'DEFAULT' => '',
            'MAXLENGTH' => ($maxlength === null) ? null : strval($maxlength),
        ]));
    }
    return _form_input($name, $pretty_name, $description, $input, $num_required > 0, false, $tabindex, false, true);
}

/**
 * Get the Tempcode for a multi-username input widget.
 *
 * @param  mixed $pretty_name A human intelligible name for this input field, provided in plain-text format (string or Tempcode)
 * @param  mixed $description A description for this input field, provided in HTML format (string or Tempcode)
 * @param  ID_TEXT $name The base parameter name which this input field is for (as this takes multiple parameters, they are named <name><x>). This name must end with '_'.
 * @param  array $default_array An array of lines to use as default (at least this many lines, filled by this array, will be presented by default)
 * @param  integer $num_required The minimum number of inputs allowed
 * @param  boolean $needs_match Whether this is a required input field
 * @param  ?integer $tabindex The tab index of the field (null: not specified)
 * @return Tempcode The input field
 */
function form_input_username_multi($pretty_name, $description, string $name, array $default_array, int $num_required, bool $needs_match = true, ?int $tabindex = null) : object
{
    if (substr($name, -1) != '_') {
        $name .= '_';
    }

    require_javascript('ajax_people_lists');

    $tabindex = get_form_field_tabindex($tabindex);

    if ($num_required == 0) {
        $required = filter_form_field_required($name, false);
        if ($required) {
            $num_required = 1;
        }
    }

    $input = new Tempcode();
    $i = 0;
    foreach ($default_array as $default) {
        if ($default === null) {
            $default = '';
        }
        $default = filter_form_field_default($name, $default);

        $_required = ($i < $num_required) ? '-required' : '';
        $input->attach(do_template('FORM_SCREEN_INPUT_USERNAME_MULTI', ['_GUID' => 'f2adcb1464b13e339a0336db6d5228cb', 'PRETTY_NAME' => $pretty_name, 'TABINDEX' => strval($tabindex), 'NEEDS_MATCH' => $needs_match, 'NAME_STUB' => $name, 'I' => strval($i), 'REQUIRED' => $_required, 'DEFAULT' => $default]));
        $i++;
    }
    if ($num_required > $i) {
        $_num_required = $num_required;
    } else {
        $_num_required = $i + 1;
    }
    for (; $i < $_num_required; $i++) {
        $_required = ($i < $num_required) ? '-required' : '';

        $input->attach(do_template('FORM_SCREEN_INPUT_USERNAME_MULTI', ['_GUID' => '4bc8a187ee5fac91275f66f78478a3c6', 'PRETTY_NAME' => $pretty_name, 'TABINDEX' => strval($tabindex), 'NEEDS_MATCH' => $needs_match, 'NAME_STUB' => $name, 'I' => strval($i), 'REQUIRED' => $_required, 'DEFAULT' => '']));
    }

    return _form_input($name, $pretty_name, $description, $input, $num_required > 0, false, $tabindex);
}

/**
 * Get the Tempcode for a text input (textarea).
 *
 * @param  mixed $pretty_name A human intelligible name for this input field, provided in plain-text format (string or Tempcode)
 * @param  mixed $description A description for this input field, provided in HTML format (string or Tempcode)
 * @param  ID_TEXT $name The name which this input field is for
 * @param  string $default The default value for this input field
 * @param  boolean $required Whether this is a required input field
 * @param  boolean $display_only Whether this should not be an actual input field but rather display $description as a paragraph (true: only $pretty_name, $description, $name, and $tabindex are considered)
 * @param  ?integer $tabindex The tab index of the field (null: not specified)
 * @param  boolean $scrolls Whether the field scrolls
 * @param  ?integer $maxlength The maximum length of the field (null: unlimited)
 * @param  ?integer $rows Number of rows for text input (null: default)
 * @param  ~?mixed $autocomplete The autocomplete field name. (false: explicitly disable autocomplete) (null: no autocomplete attribute unless there's a default for this $name)
 * @return Tempcode The input field
 */
function form_input_text($pretty_name, $description, string $name, string $default, bool $required, bool $display_only = false, ?int $tabindex = null, bool $scrolls = false, ?int $maxlength = null, ?int $rows = null, $autocomplete = null) : object
{
    $tabindex = get_form_field_tabindex($tabindex);

    $default = filter_form_field_default($name, $default);
    $required = filter_form_field_required($name, $required);

    check_suhosin_request_size(strlen($default));

    $autocomplete = _get_autocomplete_attribute_value($name, $autocomplete);

    $_required = ($required) ? '-required' : '';

    $input = do_template('FORM_SCREEN_INPUT_TEXT', [
        '_GUID' => '01626015c6ae36b1027e35e66a8b5d0b',
        'RAW' => true,
        'SCROLLS' => $scrolls,
        'ROWS' => ($rows === null) ? null : strval($rows),
        'TABINDEX' => strval($tabindex),
        'REQUIRED' => $_required,
        'NAME' => $name,
        'DEFAULT' => $default,
        'MAXLENGTH' => ($maxlength === null) ? null : strval($maxlength),
        'AUTOCOMPLETE' => $autocomplete,
        'DISPLAY_ONLY' => $display_only
    ]);
    return _form_input($name, $pretty_name, $description, $input, $required, false, $tabindex, true);
}

/**
 * Get the Tempcode for a Comcode-enabled text input (textarea).
 *
 * @param  mixed $pretty_name A human intelligible name for this input field, provided in plain-text format (string or Tempcode)
 * @param  mixed $description A description for this input field, provided in HTML format (string or Tempcode)
 * @param  ID_TEXT $name The name which this input field is for
 * @param  string $default The default value for this input field
 * @param  boolean $required Whether this is a required input field
 * @param  ?integer $tabindex The tab index of the field (null: not specified)
 * @param  boolean $force_non_wysiwyg Force non-WYSIWYG and non default-Comcode parsing
 * @param  mixed $description_side A secondary side description for this input field, provided in HTML format (string or Tempcode)
 * @param  ?Tempcode $default_parsed The parsed Comcode. (null: calculate)
 * @param  boolean $scrolls Whether the field scrolls
 * @param  ?integer $rows Number of rows for text input (null: default)
 * @param  ~?mixed $autocomplete The autocomplete field name. (false: explicitly disable autocomplete) (null: no autocomplete attribute unless there's a default for this $name)
 * @return Tempcode The input field
 */
function form_input_text_comcode($pretty_name, $description, string $name, string $default, bool $required, ?int $tabindex = null, bool $force_non_wysiwyg = false, $description_side = '', ?object $default_parsed = null, bool $scrolls = false, ?int $rows = null, $autocomplete = null) : object
{
    if ((browser_matches('wysiwyg', $default)) && (!$force_non_wysiwyg) && (strpos($default, '{$,page hint: no_wysiwyg}') === false)) {
        return form_input_huge_comcode($pretty_name, $description, $name, $default, $required, $tabindex, 10, $description_side, $default_parsed, $scrolls);
    }

    require_lang('comcode');

    handle_default_comcode_text($default);

    $autocomplete = _get_autocomplete_attribute_value($name, $autocomplete);

    $tabindex = get_form_field_tabindex($tabindex);

    $_required = ($required) ? '-required' : '';
    $default_parsed = new Tempcode();

    $default = filter_form_field_default($name, $default);
    $required = filter_form_field_required($name, $required);

    check_suhosin_request_size(strlen($default));

    if (!$force_non_wysiwyg) {
        attach_wysiwyg();

        $w = (browser_matches('wysiwyg', $default) && (strpos($default, '{$,page hint: no_wysiwyg}') === false));
        if ($w) {
            $_required .= ' wysiwyg';
        }
        push_lax_comcode(true);
        $GLOBALS['COMCODE_PARSE_URLS_CHECKED'] = 100; // FUDGE: Little hack to stop it checking any URLs
        /*Actually we reparse always to ensure it is done in semiparse mode if ($default_parsed === null) */
        $default_parsed = @comcode_to_tempcode($default, null, false, null, null, COMCODE_SEMIPARSE_MODE);
        pop_lax_comcode();
    } else {
        $w = false;
        $default_parsed = null;
    }

    $input = do_template('FORM_SCREEN_INPUT_TEXT', [
        '_GUID' => 'ff53196e943e7b19bc72fc3bbb3238b5',
        'SCROLLS' => $scrolls,
        'ROWS' => ($rows === null) ? (((is_object($description_side)) || ($description_side != '')) ? '16' : '8') : strval($rows),
        'TABINDEX' => strval($tabindex),
        'REQUIRED' => $_required,
        'NAME' => $name,
        'DEFAULT' => $default,
        'DEFAULT_PARSED' => $default_parsed,
        'AUTOCOMPLETE' => $autocomplete,
        'RAW' => false,
    ]);

    return _form_input($name, $pretty_name, $description, $input, $required, true, $tabindex, $w, false, $description_side);
}

/**
 * Get the Tempcode for a huge Comcode-enabled text input (textarea). These need extra space to fit.
 * This function is also used as an automatic replacement for form_input_text_comcode if WYSIWYG is available (as WYSIWYG needs more space too).
 *
 * @param  mixed $pretty_name A human intelligible name for this input field, provided in plain-text format (string or Tempcode)
 * @param  mixed $description A description for this input field, provided in HTML format (string or Tempcode)
 * @param  ID_TEXT $name The name which this input field is for
 * @param  string $default The default value for this input field
 * @param  boolean $required Whether this is a required input field
 * @param  ?integer $tabindex The tab index of the field (null: not specified)
 * @param  integer $rows The number of rows to use
 * @param  mixed $description_side A secondary side description for this input field, provided in HTML format (string or Tempcode)
 * @param  ?Tempcode $default_parsed The parsed Comcode. (null: calculate)
 * @param  boolean $scrolls Whether the field scrolls
 * @param  boolean $force_non_wysiwyg Force non-WYSIWYG and non default-Comcode parsing
 * @return Tempcode The input field
 */
function form_input_huge_comcode($pretty_name, $description, string $name, string $default, bool $required, ?int $tabindex = null, int $rows = 20, $description_side = '', ?object $default_parsed = null, bool $scrolls = false, bool $force_non_wysiwyg = false) : object
{
    require_lang('comcode');

    handle_default_comcode_text($default);

    $tabindex = get_form_field_tabindex($tabindex);

    $default = filter_form_field_default($name, $default);
    $required = filter_form_field_required($name, $required);

    check_suhosin_request_size(strlen($default));

    $_required = ($required) ? '-required' : '';
    $default_parsed = new Tempcode();

    attach_wysiwyg();

    if (!$force_non_wysiwyg) {
        $w = (browser_matches('wysiwyg', $default) && (strpos($default, '{$,page hint: no_wysiwyg}') === false));
        if ($w) {
            $_required .= ' wysiwyg';
        }
        push_lax_comcode(true);
        $GLOBALS['COMCODE_PARSE_URLS_CHECKED'] = 100; // FUDGE: Little hack to stop it checking any URLs
        /*Actually we reparse always to ensure it is done in semiparse mode if ($default_parsed === null) */
        $default_parsed = @comcode_to_tempcode($default, null, false, null, null, COMCODE_SEMIPARSE_MODE);
        pop_lax_comcode();
    } else {
        $w = false;
        $default_parsed = null;
    }

    $help_zone = get_comcode_zone('userguide_comcode', false);
    $_comcode = ($help_zone === null) ? new Tempcode() : do_template('COMCODE_MESSAGE', ['_GUID' => 'fbcf2413f754ca5829b9f4c908746843', 'NAME' => $name, 'W' => $w, 'URL' => build_url(['page' => 'userguide_comcode'], $help_zone)]);

    return do_template('FORM_SCREEN_INPUT_HUGE_COMCODE', [
        '_GUID' => 'b8231827be2f4a00e12fcd8986119588',
        'SCROLLS' => $scrolls,
        'DESCRIPTION_SIDE' => $description_side,
        'REQUIRED' => $required,
        '_REQUIRED' => $_required,
        'TABINDEX' => strval($tabindex),
        'COMCODE' => $_comcode,
        'PRETTY_NAME' => $pretty_name,
        'DESCRIPTION' => $description,
        'NAME' => $name,
        'DEFAULT' => $default,
        'DEFAULT_PARSED' => $default_parsed,
        'ROWS' => strval($rows),
    ]);
}

/**
 * Get the Tempcode for a huge text input (textarea).
 *
 * @param  mixed $pretty_name A human intelligible name for this input field, provided in plain-text format (string or Tempcode)
 * @param  mixed $description A description for this input field, provided in HTML format (string or Tempcode)
 * @param  ID_TEXT $name The name which this input field is for
 * @param  string $default The default value for this input field
 * @param  boolean $required Whether this is a required input field
 * @param  ?integer $tabindex The tab index of the field (null: not specified)
 * @param  integer $rows The number of rows to use
 * @param  mixed $description_side A secondary side description for this input field, provided in HTML format (string or Tempcode)
 * @param  boolean $scrolls Whether the field scrolls
 * @param  ~?mixed $autocomplete The autocomplete field name. (false: explicitly disable autocomplete) (null: no autocomplete attribute unless there's a default for this $name)
 * @return Tempcode The input field
 */
function form_input_huge($pretty_name, $description, string $name, string $default, bool $required, ?int $tabindex = null, int $rows = 20, $description_side = '', bool $scrolls = false, $autocomplete = null) : object
{
    $tabindex = get_form_field_tabindex($tabindex);

    $default = filter_form_field_default($name, $default);
    $required = filter_form_field_required($name, $required);

    check_suhosin_request_size(strlen($default));

    $_required = ($required) ? '-required' : '';
    $default_parsed = new Tempcode();

    $autocomplete = _get_autocomplete_attribute_value($name, $autocomplete);

    return do_template('FORM_SCREEN_INPUT_HUGE', [
        '_GUID' => '9d51961cd53c3fcadb8f83b905b2bbea',
        'RAW' => true,
        'SCROLLS' => $scrolls,
        'DESCRIPTION_SIDE' => $description_side,
        'REQUIRED' => $required,
        '_REQUIRED' => $_required,
        'TABINDEX' => strval($tabindex),
        'PRETTY_NAME' => $pretty_name,
        'DESCRIPTION' => $description,
        'NAME' => $name,
        'DEFAULT' => $default,
        'ROWS' => strval($rows),
        'AUTOCOMPLETE' => $autocomplete,
    ]);
}

/**
 * Get the Tempcode for a password input.
 *
 * @param  mixed $pretty_name A human intelligible name for this input field, provided in plain-text format (string or Tempcode)
 * @param  mixed $description A description for this input field, provided in HTML format (string or Tempcode)
 * @param  ID_TEXT $name The name which this input field is for
 * @param  boolean $required Whether this is a required input field
 * @param  ?integer $tabindex The tab index of the field (null: not specified)
 * @param  string $default The default value for this input field
 * @param  ~?mixed $autocomplete The autocomplete field name. (false: explicitly disable autocomplete) (null: no autocomplete attribute unless there's a default for this $name)
 * @param  boolean $password_strength Show a password strength meter
 * @return Tempcode The input field
 */
function form_input_password($pretty_name, $description, string $name, bool $required, ?int $tabindex = null, string $default = '', $autocomplete = null, bool $password_strength = false) : object
{
    $tabindex = get_form_field_tabindex($tabindex);

    $autocomplete = _get_autocomplete_attribute_value($name, $autocomplete);

    if ($autocomplete === 'autocomplete-disabled') {
        $autocomplete = 'new-password'; // More reliable for disabling autofill on password fields
    }

    $_required = ($required) ? '-required' : '';
    $input = do_template('FORM_SCREEN_INPUT_PASSWORD', [
        '_GUID' => '12af7290441ebf5459feefaf9daa28c6',
        'TABINDEX' => strval($tabindex),
        'REQUIRED' => $_required,
        'NAME' => $name,
        'VALUE' => $default,
        'AUTOCOMPLETE' => $autocomplete,
        'PASSWORD_STRENGTH' => $password_strength,
    ]);
    return _form_input($name, $pretty_name, $description, $input, $required, false, $tabindex);
}

/**
 * Get the Tempcode for a checkbox input.
 *
 * @param  mixed $pretty_name A human intelligible name for this input field, provided in plain-text format (string or Tempcode)
 * @param  mixed $description A description for this input field, provided in HTML format (string or Tempcode)
 * @param  ID_TEXT $name The name which this input field is for
 * @param  boolean $ticked Whether this is ticked (checked) by default
 * @param  ?integer $tabindex The tab index of the field (null: not specified)
 * @param  ID_TEXT $value The value the checkbox passes when ticked (checked)
 * @param  boolean $read_only Whether this box should be read only
 * @param  boolean $disabled Whether this box should be disabled
 * @return Tempcode The input field
 */
function form_input_tick($pretty_name, $description, string $name, bool $ticked, ?int $tabindex = null, string $value = '1', bool $read_only = false, bool $disabled = false) : object
{
    $tabindex = get_form_field_tabindex($tabindex);

    $ticked = (filter_form_field_default($name, $ticked ? '1' : '0') == '1');

    $input = do_template('FORM_SCREEN_INPUT_TICK', [
        '_GUID' => '340a68c271b838d327f042d101df27eb',
        'VALUE' => $value,
        'CHECKED' => $ticked,
        'TABINDEX' => strval($tabindex),
        'NAME' => $name,
        'READ_ONLY' => $read_only,
        'DISABLED' => $disabled,
    ]);
    return _form_input($name, $pretty_name, $description, $input, false, false, $tabindex);
}

/**
 * Get the Tempcode for a bank of tick (check) boxes.
 *
 * @param  array $options A list of tuples (asterisk are required): (prettyname*, name*, value, description, disabled, ticked (null or not defined: true if value is truthy))
 * @param  mixed $description A description for this input field, provided in HTML format (string or Tempcode)
 * @param  ?integer $_tabindex The tab index of the field (null: not specified)
 * @param  mixed $_pretty_name A human intelligible name for this input field, provided in plain-text format (string or Tempcode) (blank: use default)
 * @param  boolean $simple_style Whether to place each tick (check) on a new line
 * @param  ?ID_TEXT $custom_name Name for custom value to be entered to (null: no custom value allowed)
 * @param  ?mixed $custom_value Value for custom value, string (accept single value) or array (accept multiple values) (null: no custom value known)
 * @return Tempcode The input field
 */
function form_input_various_ticks(array $options, $description, ?int $_tabindex = null, $_pretty_name = '', bool $simple_style = false, ?string $custom_name = null, $custom_value = null) : object
{
    if (empty($options)) {
        return new Tempcode();
    }

    $options = array_values($options);

    if ($_tabindex === null) {
        $tabindex = get_form_field_tabindex(null);
    } else {
        $_tabindex++;
        $tabindex = $_tabindex;
    }

    if ((is_string($_pretty_name)) && ($_pretty_name == '')) {
        $_pretty_name = do_lang_tempcode('OPTIONS');
    }

    $input = new Tempcode();

    if (count($options[0]) != 3) {
        $options = [[$options, null, new Tempcode()]];
    }
    foreach ($options as $_option) {
        $out = [];
        foreach ($_option[0] as $option) {
            list($pretty_name, $name, $_value, $_description) = $option;

            if (($_value === false) || ($_value === null) || ($_value === '')) {
                $value = '0';
            } elseif ($_value === true) {
                $value = '1';
            } else {
                $value = $_value;
            }

            $value = (filter_form_field_default($name, $value));

            $disabled = !empty($option[4]) && $option[4];
            $ticked = ((isset($option[5]) && $option[5]) || (!isset($option[5]) && ($value != '0') && ($value != '')));

            $out[] = ['DISABLED' => $disabled, 'CHECKED' => $ticked, 'VALUE' => $value, 'TABINDEX' => strval($tabindex), 'NAME' => $name, 'PRETTY_NAME' => $pretty_name, 'DESCRIPTION' => $_description];
        }

        if ($custom_value === []) {
            $custom_value = [''];
        }

        $input->attach(do_template('FORM_SCREEN_INPUT_VARIOUS_TICKS', [
            '_GUID' => 'a6212f61304a101fb2754e334a8b4212',
            'CUSTOM_ACCEPT_MULTIPLE' => is_array($custom_value),
            'CUSTOM_NAME' => $custom_name,
            'CUSTOM_VALUE' => ($custom_value === null) ? '' : $custom_value,
            'SECTION_TITLE' => $_option[2],
            'EXPANDED' => $_option[1],
            'SIMPLE_STYLE' => $simple_style,
            'SIBLINGS' => strval(count($out)),
            'OUT' => $out,
        ]));
    }
    return _form_input('', $_pretty_name, $description, $input, false, false, $tabindex);
}

/**
 * Get the Tempcode for a multi-set upload, allowing selection from multiple different sources.
 * Mainly for images, used with post_param_image or post_param_multi_source_upload.
 *
 * @param  mixed $set_title A human intelligible name for this input field, provided in plain-text format or as HTML via do_lang_tempcode/protect_from_escaping (string or Tempcode)
 * @param  mixed $set_description A description for this input field, provided in HTML format (string or Tempcode)
 * @param  Tempcode $hidden A reference to where we're putting hidden fields
 * @param  ID_TEXT $set_name The name which this input field is for (actually, this is a prefix put in front of different input types)
 * @param  ?ID_TEXT $theme_image_type The directory of theme images to provide selection from (null: do not support theme images)
 * @param  boolean $required Whether this is a required input field. Set this to false if you are using this field on an edit form and already have an uploaded file -- therefore you'd know no new file would mean not to replace the existing file
 * @param  ?string $default The default value for the field (null: none) (blank: none). Should only be passed if $required is false, because it creates a delete button for the existing file, implying that leaving it with no file is valid
 * @param  boolean $support_syndication Whether to syndicate the file off
 * @param  ?string $filter File type filter (null: autodetect)
 * @param  ?integer $images_only IMAGE_CRITERIA_* bitmask for images (null: not restricted to images)
 * @return Tempcode The input field
 */
function form_input_upload_multi_source($set_title, $set_description, object &$hidden, string $set_name = 'image', ?string $theme_image_type = null, bool $required = true, ?string $default = null, bool $support_syndication = false, ?string $filter = null, ?int $images_only = 0) : object
{
    require_code('images');

    // Remap theme image to URL if needed
    if ($images_only !== null) {
        if (($theme_image_type !== null) && (get_option('allow_theme_image_selector') == '1')) {
            require_code('themes2');
            $ids = get_all_image_ids_type($theme_image_type);

            if (in_array($default, $ids)) {
                $default = find_theme_image($default);
            }
        }
    }

    // ---

    // Upload
    // ------

    $field_file = $set_name . '__upload';

    if ($support_syndication) {
        require_code('upload_syndication');
        list($syndication_json,) = get_upload_syndication_json(CMS_UPLOAD_IMAGE);
    } else {
        $syndication_json = null;
    }
    if ($filter === null) {
        if ($images_only !== null) {
            $filter = get_allowed_image_file_types($images_only); // We don't use the filter from get_upload_syndication_json because we're not restricted to what can syndicate
        } else {
            $filter = '';
        }
    }

    if (get_value('disable_multi_homed_upload__' . get_page_name()) === '1') {
        if ($default != '') {
            $required = false; // As we already have it. But we pass $default as '' so it does not offer deletion. The current URL is passed through the URL setting.
        }

        $upload_widget = form_input_upload(do_lang_tempcode('UPLOAD'), do_lang_tempcode('DESCRIPTION_UPLOAD'), $field_file, $required, null, null, true, $filter, $syndication_json);

        $hidden_url_widget = form_input_hidden($set_name . '__url', $default);
        $hidden->attach($hidden_url_widget);

        return $upload_widget;
    }

    $field_set = alternate_fields_set__start($set_name);

    $upload_widget = form_input_upload(do_lang_tempcode('UPLOAD'), do_lang_tempcode('DESCRIPTION_UPLOAD'), $field_file, $required, $default, null, true, $filter, $syndication_json);

    $field_set->attach($upload_widget);

    if ($images_only !== null) {
        handle_max_file_size($hidden, 'image');
    }

    // URL
    // ---

    $field_url = $set_name . '__url';

    $url_widget = form_input_url(do_lang_tempcode('URL'), do_lang_tempcode('DESCRIPTION_ALTERNATE_URL'), $field_url, $default, $required);

    $field_set->attach($url_widget);

    // Filedump
    // --------

    if ((addon_installed('filedump')) && (has_actual_page_access(null, 'filedump'))) {
        require_code('files2');
        $full_path = get_custom_file_base() . '/uploads/filedump';
        $files = get_directory_contents($full_path, '', IGNORE_ACCESS_CONTROLLERS, false);
        $has_image_or_dir = false;
        foreach ($files as $file) {
            if (is_image($file, IMAGE_CRITERIA_WEBSAFE, true) || is_dir($full_path . '/' . $file)) {
                $has_image_or_dir = true;
                break;
            }
        }
        if ($has_image_or_dir) {
            $field_filedump = $set_name . '__filedump';

            require_lang('filedump');

            $filedump_url = build_url(['page' => 'filedump'], get_module_zone('filedump'));
            if ($images_only !== null) {
                $filedump_options = ['images_only' => strval($images_only)];
            } else {
                $filedump_options = [];
            }
            if ($default === null) {
                $filedump_default = '';
            } else {
                $filedump_default = (($default !== null) && (preg_match('#^uploads/filedump/#', $default) != 0)) ? $default : '';
            }
            $filedump_field_description = do_lang_tempcode('DESCRIPTION_ALTERNATE_URL_FILEDUMP', escape_html($filedump_url->evaluate()));
            $filedump_widget = form_input_tree_list(do_lang_tempcode('FILEDUMP'), $filedump_field_description, $field_filedump, '', 'choose_filedump_file', $filedump_options, $required, $filedump_default, false);
            $field_set->attach($filedump_widget);
        }
    }

    // Theme image
    // -----------

    if ($images_only !== null) {
        if (($theme_image_type !== null) && (get_option('allow_theme_image_selector') == '1')) {
            if (!empty($ids)) {
                $field_choose = $set_name . '__theme_image';

                $theme_image_widget = form_input_theme_image(do_lang_tempcode('STOCK'), '', $field_choose, $ids, $default, null, null, $required);

                $field_set->attach($theme_image_widget);
            }
        }
    }

    // ---

    return alternate_fields_set__end($set_name, $set_title, $set_description, $field_set, $required);
}

/**
 * Make a preview URL absolute and return if it is an image.
 *
 * @param  ?URLPATH $url URL (null: none) (blank: none)
 * @return array A pair: Modified URL, whether it is an image
 */
function make_previewable_url_absolute(?string $url) : array
{
    $_url = $url;
    $is_image = false;

    if (!cms_empty_safe($_url)) {
        if (url_is_local($_url)) {
            $image_path = get_custom_file_base() . '/' . dirname(rawurldecode($_url));
            if (!is_file($image_path)) {
                $image_path = get_file_base() . '/' . dirname(rawurldecode($_url));
                $custom = false;
            } else {
                $custom = true;
            }

            $htaccess_path = $image_path . '/.htaccess';
            if ((is_file($htaccess_path)) && (stripos(cms_file_get_contents_safe($htaccess_path, FILE_READ_LOCK), 'Require not ip') !== false)) {
                return [$_url, $is_image];
            }
            $_url = ($custom ? get_custom_base_url() : get_base_url()) . '/' . $_url;
        }

        require_code('images');
        $is_image = is_image($_url, IMAGE_CRITERIA_WEBSAFE, true);
    }

    return [$_url, $is_image];
}

/**
 * Get the Tempcode for a file upload input.
 *
 * @param  mixed $pretty_name A human intelligible name for this input field, provided in plain-text format (string or Tempcode)
 * @param  mixed $description A description for this input field, provided in HTML format (string or Tempcode)
 * @param  ID_TEXT $name The name which this input field is for
 * @param  boolean $required Whether this is a required input field. Set this to false if you are using this field on an edit form and already have an uploaded file -- therefore you'd know no new file would mean not to replace the existing file
 * @param  ?string $default The default value for the field (null: none) (blank: none). Should only be passed if $required is false, because it creates a delete button for the existing file, implying that leaving it with no file is valid
 * @param  ?integer $tabindex The tab index of the field (null: not specified)
 * @param  boolean $plupload Whether plupload-style is preferred
 * @param  string $filter File-type filter to limit to, comma-separated file extensions (might not be supported)
 * @param  ?string $syndication_json JSON structure of what uploader syndications there will be (null: none)
 * @return Tempcode The input field
 */
function form_input_upload($pretty_name, $description, string $name, bool $required, ?string $default = null, ?int $tabindex = null, bool $plupload = true, string $filter = '', ?string $syndication_json = null) : object
{
    if ($plupload) {
        require_javascript('plupload');
        require_css('widget_plupload');
    }

    if ($default === '') {
        $default = null;
    }

    $tabindex = get_form_field_tabindex($tabindex);

    $_required = ($required) ? '-required' : '';

    list($existing_url, $is_image) = make_previewable_url_absolute($default);

    $input = do_template('FORM_SCREEN_INPUT_UPLOAD', [
        '_GUID' => 'f493edcc5298bb32fff8635f2d316d21',
        'FILTER' => $filter,
        'PRETTY_NAME' => $pretty_name,
        'EXISTING_URL' => $existing_url,
        'IS_IMAGE' => $is_image,
        'PLUPLOAD' => $plupload,
        'EDIT' => ($default !== null),
        'TABINDEX' => strval($tabindex),
        'REQUIRED' => $_required,
        'NAME' => $name,
        'SYNDICATION_JSON' => $syndication_json,
    ]);
    return _form_input($name, $pretty_name, $description, $input, $required, false, $tabindex);
}

/**
 * Get the Tempcode for a multiple file upload input.
 *
 * @param  mixed $pretty_name A human intelligible name for this input field, provided in plain-text format (string or Tempcode)
 * @param  mixed $description A description for this input field, provided in HTML format (string or Tempcode)
 * @param  string $name The base name which this input field is for
 * @param  boolean $required Whether this is a required input field
 * @param  ?integer $tabindex The tab index of the field (null: not specified)
 * @param  ?array $default The default value for the field (null: none)
 * @param  boolean $plupload Whether plupload-style is preferred
 * @param  string $filter File-type filter to limit to, comma-separated file extensions (might not be supported)
 * @param  ?string $syndication_json JSON structure of what uploader syndications there will be (null: none)
 * @return Tempcode The input field
 */
function form_input_upload_multi($pretty_name, $description, string $name, bool $required, ?int $tabindex = null, ?array $default = null, bool $plupload = true, string $filter = '', ?string $syndication_json = null) : object
{
    if ($plupload) {
        require_javascript('plupload');
        require_css('widget_plupload');
    }

    $tabindex = get_form_field_tabindex($tabindex);

    $_required = ($required) ? '-required' : '';

    $edit = [];
    if (($default !== null) && (!empty($default))) {
        foreach ($default as $i => $_file) {
            $file_bits = explode('::', $_file);
            $image_url = '';
            list($_edit, $is_image) = make_previewable_url_absolute($file_bits[0]);

            // If first bit is not an image, sometimes the second bit will be (e.g. video thumbnails)
            if (($is_image === false) && array_key_exists(1, $file_bits)) {
                list($image_url, $is_image) = make_previewable_url_absolute($file_bits[1]);
            } elseif ($is_image === true) {
                $image_url = $_edit;
            }

            $edit[] = ['INDEX' => strval($i), 'URL' => $_edit, 'IMAGE_URL' => $image_url, 'IS_IMAGE' => $is_image];
        }
    }
    $input = do_template('FORM_SCREEN_INPUT_UPLOAD_MULTI', [
        '_GUID' => 'e8712ede08591604738762ac03852ac1',
        'TABINDEX' => strval($tabindex),
        'EDIT' => $edit,
        'FILTER' => $filter,
        'REQUIRED' => $_required,
        'PLUPLOAD' => $plupload,
        'NAME' => $name,
        'I' => '1',
        'NAME_STUB' => $name,
        'SYNDICATION_JSON' => $syndication_json,
    ]);
    return _form_input('', $pretty_name, $description, $input, $required, false, $tabindex, false, true);
}

/**
 * Get the Tempcode for a listbox.
 *
 * @param  mixed $pretty_name A human intelligible name for this input field, provided in plain-text format (string or Tempcode)
 * @param  mixed $description A description for this input field, provided in HTML format (string or Tempcode)
 * @param  ID_TEXT $name The name which this input field is for
 * @param  Tempcode $content The list entries for our list; you compose these via attaching together form_input_list_entry calls
 * @param  ?integer $tabindex The tab index of the field (null: not specified)
 * @param  boolean $inline_list Whether this is an inline displayed list as opposed to a dropdown
 * @param  boolean $required Whether this is required
 * @param  ?array $images List of theme images that $content is allowing selection of (so that we can show the images within the list, if JS is enabled) (null: none)
 * @param  integer $size How much space the list takes up (inline lists only)
 * @param  ~?mixed $autocomplete The autocomplete field name. (false: explicitly disable autocomplete) (null: no autocomplete attribute unless there's a default for this $name)
 * @return Tempcode The input field
 */
function form_input_list($pretty_name, $description, string $name, object $content, ?int $tabindex = null, bool $inline_list = false, bool $required = true, ?array $images = null, int $size = 5, $autocomplete = null) : object
{
    $tabindex = get_form_field_tabindex($tabindex);

    require_css('widget_select2');
    require_javascript('jquery');
    require_javascript('select2');

    $_required = ($required) ? '-required' : '';

    $autocomplete = _get_autocomplete_attribute_value($name, $autocomplete);

    $input = do_template('FORM_SCREEN_INPUT_LIST', [
        '_GUID' => '112dd79a8e0069aa21615594aec1e509',
        'TABINDEX' => strval($tabindex),
        'REQUIRED' => $_required,
        'NAME' => $name,
        'CONTENT' => $content,
        'INLINE_LIST' => $inline_list,
        'IMAGES' => $images,
        'SIZE' => strval($size),
        'AUTOCOMPLETE' => $autocomplete,
    ]);
    return _form_input($name, $pretty_name, $description, $input, $required, false, $tabindex);
}

/**
 * Get the Tempcode for a huge listbox.
 *
 * @param  mixed $pretty_name A human intelligible name for this input field, provided in plain-text format (string or Tempcode)
 * @param  mixed $description A description for this input field, provided in HTML format (string or Tempcode)
 * @param  ID_TEXT $name The name which this input field is for
 * @param  Tempcode $content The list entries for our list; you compose these via attaching together form_input_list_entry calls
 * @param  ?integer $tabindex The tab index of the field (null: not specified)
 * @param  boolean $inline_list Whether this is an inline displayed list as opposed to a dropdown
 * @param  boolean $required Whether this is required
 * @param  ?integer $size Size of list (null: default)
 * @param  ~?mixed $autocomplete The autocomplete field name. (false: explicitly disable autocomplete) (null: no autocomplete attribute unless there's a default for this $name)
 * @return Tempcode The input field
 */
function form_input_huge_list($pretty_name, $description, string $name, object $content, ?int $tabindex = null, bool $inline_list = false, bool $required = true, ?int $size = null, $autocomplete = null) : object
{
    $tabindex = get_form_field_tabindex($tabindex);

    require_css('widget_select2');
    require_javascript('jquery');
    require_javascript('select2');

    $_required = ($required) ? '-required' : '';

    $autocomplete = _get_autocomplete_attribute_value($name, $autocomplete);

    return do_template('FORM_SCREEN_INPUT_HUGE_LIST', [
        '_GUID' => 'b29dbbaf09bb5c36410e22feafa2f968',
        'TABINDEX' => strval($tabindex),
        'SIZE' => ($size === null) ? null : strval($size),
        'REQUIRED' => $_required,
        'PRETTY_NAME' => $pretty_name,
        'DESCRIPTION' => $description,
        'NAME' => $name,
        'CONTENT' => $content,
        'INLINE_LIST' => $inline_list,
        'AUTOCOMPLETE' => $autocomplete,
    ]);
}

/**
 * Get the Tempcode for a listbox with multiple selections.
 *
 * @param  mixed $pretty_name A human intelligible name for this input field, provided in plain-text format (string or Tempcode)
 * @param  mixed $description A description for this input field, provided in HTML format (string or Tempcode)
 * @param  ID_TEXT $name The name which this input field is for
 * @param  Tempcode $content The list entries for our list; you compose these via attaching together form_input_list_entry calls
 * @param  ?integer $tabindex The tab index of the field (null: not specified)
 * @param  integer $size How much space the list takes up
 * @param  boolean $required Whether at least one must be selected
 * @param  ?ID_TEXT $custom_name Name for custom value to be entered to (null: no custom value allowed)
 * @param  ?mixed $custom_value Value for custom value, string (accept single value) or array (accept multiple values) (null: no custom value known)
 * @return Tempcode The input field
 */
function form_input_multi_list($pretty_name, $description, string $name, object $content, ?int $tabindex = null, int $size = 5, bool $required = false, ?string $custom_name = null, $custom_value = null) : object
{
    $tabindex = get_form_field_tabindex($tabindex);

    require_css('widget_select2');
    require_javascript('jquery');
    require_javascript('select2');

    $input = do_template('FORM_SCREEN_INPUT_MULTI_LIST', [
        '_GUID' => 'ed0739205c0bf5039e1d4fe2ddfc06da',
        'TABINDEX' => strval($tabindex),
        'SIZE' => strval($size),
        'NAME' => $name,
        'CONTENT' => $content,
        'CUSTOM_ACCEPT_MULTIPLE' => is_array($custom_value),
        'CUSTOM_NAME' => $custom_name,
        'CUSTOM_VALUE' => ($custom_value === null) ? '' : $custom_value,
        'INLINE' => (peek_field_encapsulation() == FIELD_ENCAPSULATION_RAW),
    ]);
    return _form_input($name, $pretty_name, $description, $input, $required, false, $tabindex);
}

/**
 * Get the Tempcode for a combo-box (listbox with free text input). Works best if HTML5 is available.
 *
 * @param  mixed $pretty_name A human intelligible name for this input field, provided in plain-text format (string or Tempcode)
 * @param  mixed $description A description for this input field, provided in HTML format (string or Tempcode)
 * @param  ID_TEXT $name The name which this input field is for
 * @param  string $default Current selection
 * @param  Tempcode $options The list entries for our list; you compose these via attaching together form_input_list_entry calls
 * @param  ?integer $tabindex The tab index of the field (null: not specified)
 * @param  boolean $required Whether this is required
 * @param  ~?mixed $autocomplete The autocomplete field name. (false: explicitly disable autocomplete) (null: no autocomplete attribute unless there's a default for this $name)
 * @return Tempcode The input field
 */
function form_input_combo($pretty_name, $description, string $name, string $default, object $options, ?int $tabindex = null, bool $required = true, $autocomplete = null) : object
{
    $tabindex = get_form_field_tabindex($tabindex);

    $autocomplete = _get_autocomplete_attribute_value($name, $autocomplete);

    $_required = ($required) ? '-required' : '';
    $input = do_template('FORM_SCREEN_INPUT_COMBO', [
        '_GUID' => '4f4595c0b0dfcf09b004481e317d02a8',
        'TABINDEX' => strval($tabindex),
        'REQUIRED' => $_required,
        'NAME' => $name,
        'CONTENT' => $options,
        'DEFAULT' => $default,
        'AUTOCOMPLETE' => $autocomplete,
    ]);
    return _form_input($name, $pretty_name, $description, $input, $required, false, $tabindex);
}

/**
 * Get the Tempcode for an AJAX-powered tree listbox.
 *
 * @param  mixed $pretty_name A human intelligible name for this input field, provided in plain-text format (string or Tempcode)
 * @param  mixed $description A description for this input field, provided in HTML format (string or Tempcode)
 * @param  ID_TEXT $name The name which this input field is for
 * @param  ?ID_TEXT $root_id The ID to do under (null: root)
 * @param  string $hook The ajax tree-list hook that drives our data
 * @param  array $options A map of special options
 * @param  boolean $required Whether this is a required input field
 * @param  ?string $default The default value for the field (null: none)
 * @param  boolean $use_server_id Whether to use the server-ID in the list instead of the ID in the list
 * @param  ?integer $tabindex The tab index of the field (null: not specified)
 * @param  boolean $multi_select Whether multiple selections are allowed
 * @param  ?string $nice_label Label for default value (null: just use the literal)
 * @return Tempcode The input field
 */
function form_input_tree_list($pretty_name, $description, string $name, ?string $root_id, string $hook, array $options, bool $required, ?string $default = null, bool $use_server_id = false, ?int $tabindex = null, bool $multi_select = false, ?string $nice_label = null) : object
{
    require_javascript('tree_list');

    require_code('hooks/systems/ajax_tree/' . filter_naughty_harsh($hook));
    $object = object_factory('Hook_ajax_tree_' . filter_naughty_harsh($hook));

    if (get_option('tree_lists') == '0') {
        $simple_content = new Tempcode();
        $simple_content->attach(form_input_list_entry('', false, do_lang('NA')));
        $simple_content->attach($object->simple($root_id, $options, $default));

        if ($simple_content->is_empty()) {
            if ($required) {
                inform_exit(do_lang_tempcode('NO_OF_THIS', $pretty_name));
            }
            return new Tempcode();
        }
        if ($multi_select) {
            return form_input_multi_list($pretty_name, $description, $name, $simple_content, $tabindex, 10, $required);
        }

        return form_input_list($pretty_name, $description, $name, $simple_content, $tabindex, false, $required);
    }

    $tabindex = get_form_field_tabindex($tabindex);

    if ($nice_label === null) {
        $nice_label = $default;
    }

    require_code('content');
    $content_type = preg_replace('#^choose_#', '', $hook);
    if ($content_type != '') {
        $cma_ob = get_content_object($content_type);
        if ($cma_ob !== null) {
            $cma_info = $cma_ob->info();
            if ($cma_info !== null) {
                if ($cma_info['parent_category_meta_aware_type'] !== null) {
                    $content_type = $cma_info['parent_category_meta_aware_type'];
                }
            }
        }
    }

    $_required = ($required) ? '-required' : '';
    $input = do_template('FORM_SCREEN_INPUT_TREE_LIST', [
        '_GUID' => '21e9644eeac24356f38459ebe37f693a',
        'MULTI_SELECT' => $multi_select,
        'NICE_LABEL' => (($nice_label === null) || $nice_label == '-1') ? '' : $nice_label,
        'USE_SERVER_ID' => $use_server_id,
        'TABINDEX' => strval($tabindex),
        'NAME' => $name,
        'REQUIRED' => $_required,
        '_REQUIRED' => $required,
        'DEFAULT' => ($default === null) ? '' : $default,
        'HOOK' => $hook,
        'ROOT_ID' => ($root_id === null) ? '' : $root_id,
        'OPTIONS' => json_encode($options),
        'DESCRIPTION' => $description,
        'CONTENT_TYPE' => $content_type,
        'END_OF_FORM' => false,
    ]);
    return _form_input($name, $pretty_name, '', $input, $required, false, $tabindex);
}

/**
 * Get the Tempcode for a complex input that chooses partials from a list ('all', 'all-except-these', or 'these').
 *
 * @param  mixed $pretty_name A human intelligible name for this input field, provided in plain-text format (string or Tempcode)
 * @param  mixed $description A description for this input field, provided in HTML format (string or Tempcode)
 * @param  string $base The base name which this input field is for
 * @param  Tempcode $list A list accumulation to select against
 * @param  string $type The current type of partial selection
 * @set + - *
 * @param  ?integer $tabindex The tab index of the field (null: not specified)
 * @return Tempcode The input field
 */
function form_input_all_and_not($pretty_name, $description, string $base, object $list, string $type = '+', ?int $tabindex = null) : object
{
    $tabindex = get_form_field_tabindex($tabindex);

    $type = filter_form_field_default($base, $type);

    $radios = new Tempcode();
    $radios->attach(form_input_radio_entry($base, '*', $type == '*', do_lang_tempcode('USE_ALL'), $tabindex));
    $radios->attach(form_input_radio_entry($base, '-', $type == '-', do_lang_tempcode('USE_ALL_EXCEPT_SELECTED'), $tabindex));
    $radios->attach(form_input_radio_entry($base, '+', $type == '+', do_lang_tempcode('USE_ALL_SELECTED'), $tabindex));
    $input = do_template('FORM_SCREEN_INPUT_ALL_AND_NOT', ['_GUID' => '32063ca0237a3b46e8fa08bb71a6e41c', 'TABINDEX' => strval($tabindex), 'BASE' => $base, 'RADIOS' => $radios, 'LIST' => $list]);
    return _form_input($base . '_list', $pretty_name, $description, $input, false, false, $tabindex);
}

/**
 * Get the Tempcode for a radio group.
 *
 * @param  mixed $pretty_name A human intelligible name for this input field, provided in plain-text format (string or Tempcode)
 * @param  mixed $description A description for this input field, provided in HTML format (string or Tempcode)
 * @param  ID_TEXT $name The name which this input field is for
 * @param  Tempcode $content The radio buttons for our radio group; you compose these via attaching together form_input_radio_entry calls
 * @param  boolean $required Whether a radio selection is required
 * @param  boolean $picture_contents Whether this is a picture-based radio list
 * @param  string $selected_path Default value (only appropriate if has picture contents)
 * @return Tempcode The input field
 */
function form_input_radio($pretty_name, $description, string $name, object $content, bool $required = false, bool $picture_contents = false, string $selected_path = '') : object
{
    $map = ['_GUID' => '26021f9ae8a0cd83b93874bfa80052ca', 'NAME' => $name, 'REQUIRED' => $required, 'CONTENT' => $content, 'IMAGES' => $picture_contents];
    if ($picture_contents) {
        $map = array_merge($map, ['CODE' => $selected_path,]);
    }
    $input = do_template('FORM_SCREEN_INPUT_RADIO_LIST', $map);
    return _form_input(($GLOBALS['DOING_ALTERNATE_FIELDS_SET'] !== null) ? $name : '', $pretty_name, $description, $input, $required);
}

/**
 * Get the Tempcode for a radio input. (You would gather together the outputs of several of these functions, then put them in as the $content in a form_input_radio function call).
 *
 * @param  string $name The name of the radio button group this will be put in (i.e. the name the value presented here will be possibly matched against)
 * @param  string $value The value for this entry
 * @param  boolean $selected Whether this entry is selected by default or not
 * @param  mixed $text The text associated with this choice, provided in HTML format (string or Tempcode) (blank: just use value for text)
 * @param  ?integer $tabindex The tab index of the field (null: not specified)
 * @param  mixed $description An additional long description, provided in HTML format (string or Tempcode) (blank: no description)
 * @param  boolean $disabled Whether this radio button is disabled and cannot be selected
 * @return Tempcode The input field
 */
function form_input_radio_entry(string $name, string $value, bool $selected = false, $text = '', ?int $tabindex = null, $description = '', bool $disabled = false) : object
{
    $tabindex = get_form_field_tabindex($tabindex);

    if ((is_string($text)) && ($text == '')) {
        $text = $value;
    }

    $selected = (filter_form_field_default($name, $selected ? '1' : '') == '1');

    return do_template('FORM_SCREEN_INPUT_RADIO_LIST_ENTRY', [
        '_GUID' => 'e2fe4ba6e8b3f705651dba13ea27f61d',
        'DESCRIPTION' => $description,
        'CHECKED' => $selected,
        'TABINDEX' => strval($tabindex),
        'NAME' => $name,
        'VALUE' => $value,
        'TEXT' => $text,
        'DISABLED' => $disabled
    ]);
}

/**
 * Get the Tempcode to choose a picture from the given list of images in the theme image system, with sub-categorisation.
 *
 * @param  mixed $pretty_name A human intelligible name for this input field, provided in plain-text format (string or Tempcode)
 * @param  mixed $description A description for this input field, provided in HTML format (string or Tempcode)
 * @param  ID_TEXT $name The name which this input field is for
 * @param  array $ids A list of image IDs (codes) we can choose from, probably found via get_all_image_ids_type
 * @param  ?URLPATH $selected_url The currently selected entry in our picture choosing, by URL (null: none)
 * @param  ?string $selected_code The currently selected entry in our picture choosing, by code (null: none)
 * @param  ?integer $tabindex The tab index of the field (null: not specified)
 * @param  boolean $allow_none Whether to allow the selection of 'no' picture
 * @param  ?object $db The database connector to the Conversr install we are choosing images from (null: site db)
 * @param  ?ID_TEXT $theme Theme to use (null: current theme)
 * @param  ?ID_TEXT $lang Language to use (null: current language)
 * @param  boolean $linear Whether choices are presented in a list (as opposed to a grid); useful when sizes are irregular
 * @param  boolean $direct_titles Whether to show direct codenames, rather than trying to prettify them into titles
 * @return Tempcode The input field
 */
function form_input_theme_image($pretty_name, $description, string $name, array $ids, ?string $selected_url = null, ?string $selected_code = null, ?int $tabindex = null, bool $allow_none = false, ?object $db = null, ?string $theme = null, ?string $lang = null, bool $linear = false, bool $direct_titles = false) : object
{
    if ($db === null) {
        $db = $GLOBALS['SITE_DB'];
    }

    if (empty($ids)) {
        return new Tempcode();
    }

    $tabindex = get_form_field_tabindex($tabindex);

    require_code('images');

    $selected_code = filter_form_field_default($name, ($selected_code === null) ? '' : $selected_code);
    if ($selected_code == '') {
        $selected_code = null;
    }

    // Split into lists of categories based on path division
    $current_path = ''; // Initialise type to string
    $current_path = null;
    $category = [];
    $categories = [];
    foreach ($ids as $id) {
        $slash_pos = strrpos($id, '/');
        if ($slash_pos === false) {
            $slash_pos = 0;
        }
        $new_path = substr($id, 0, $slash_pos);
        if ($new_path !== $current_path) {
            if ($current_path !== null) {
                if (!array_key_exists($current_path, $categories)) {
                    $categories[$current_path] = [];
                }
                $categories[$current_path] = array_merge($categories[$current_path], $category);
                $category = [];
            }
            $current_path = $new_path;
        }
        $category[] = $id;
    }
    if ($current_path !== null) {
        if (!array_key_exists($current_path, $categories)) {
            $categories[$current_path] = [];
        }
        $categories[$current_path] = array_merge($categories[$current_path], $category);
    }

    // Sorting but fudge it so 'cns_default_avatars/default_set' always comes first
    cms_mb_ksort($categories, SORT_NATURAL | SORT_FLAG_CASE);
    $avatars = (array_key_exists(0, $category)) && (substr($category[0], 0, 20) == 'cns_default_avatars/');
    if ((array_key_exists('cns_default_avatars/default_set', $categories)) && ($avatars)) {
        $def = $categories['cns_default_avatars/default_set'];
        unset($categories['cns_default_avatars/default_set']);
        $categories = array_merge(['cns_default_avatars/default_set' => $def], $categories);
    }
    // Add in the 'N/A' option
    if (($allow_none) && (!array_key_exists('', $categories))) {
        if (count($categories) == 1) {
            array_unshift($categories[$current_path], '');
        } else {
            $categories[do_lang('NA')] = [''];
        }
    }

    // Show each category
    $content = new Tempcode();
    foreach ($categories as $cat => $ids) {
        $cat = $direct_titles ? $cat : titleify($cat);

        if ($avatars) {
            $cut_pos = strpos($cat, '/');
            $cut_pos = ($cut_pos === false) ? ($avatars ? strlen($cat) : 0) : ($cut_pos + 1);
            $cat = titleify(substr($cat, $cut_pos)); // Make the category name a bit nicer
        }

        if ($cat == '') {
            $cat = do_lang($avatars ? 'GENERAL' : 'UNNAMED');
        }

        $image_maps = ['without_widths' => [], 'with_widths' => []];

        $_category = new Tempcode();
        $category_expanded = false;
        foreach ($ids as $id) {
            if ($selected_url !== null) {
                $pos = strpos($selected_url, '/' . $id);
                $selected = false;
                if ($id != '') {
                    $selected = (cms_rawurlrecode(find_theme_image($id, false, false, null, null, $db)) == cms_rawurlrecode($selected_url)) || (cms_rawurlrecode(find_theme_image($id, false, true, null, null, $db)) == cms_rawurlrecode($selected_url));
                }
                if ($selected) {
                    $selected_code = $id;
                }
            } else {
                $selected = ($selected_code == $id);
            }
            if ($selected) {
                $category_expanded = true;
            }

            if ($id == '') {
                if ($selected_code === null) {
                    $selected = true;
                }
                $url = find_theme_image('na', false, false, $theme, $lang, $db);
                $pretty = do_lang_tempcode('NA_EM');
            } else {
                $url = find_theme_image($id, $theme != 'default', false, $theme, $lang, $db);
                if ($url == '') {
                    $url = find_theme_image($id, false, false, 'default', $lang, $db);
                }
                $pretty = $direct_titles ? make_string_tempcode($id) : make_string_tempcode(titleify((strrpos($id, '/') === false) ? $id : substr($id, strrpos($id, '/') + 1)));
            }
            if ($url == '') {
                continue;
            }

            $test = cms_getimagesize_url($url, true);
            if ($test !== false) {
                list($width, $height) = $test;
            } else {
                $width = null;
                $height = null;
            }

            $image_maps[($width === null) ? 'without_widths' : 'with_widths'][] = [
                'width' => $width,
                'tpl_map' => [
                    '_GUID' => '10005e2f08b44bfe17fce68685b4c884',
                    'LINEAR' => $linear,
                    'CHECKED' => $selected,
                    'PRETTY' => $pretty,
                    'NAME' => $name,
                    'CODE' => $id,
                    'URL' => $url,
                    'WIDTH' => ($width === null) ? null : strval($width),
                    'HEIGHT' => ($height === null) ? null : strval($height),
                    'VECTOR' => (substr($url, -4) == '.svg'),
                ],
            ];
        }

        sort_maps_by($image_maps['with_widths'], 'width');

        foreach ($image_maps['without_widths'] as $image_map) {
            $tpl_map = $image_map['tpl_map'];
            if (!empty($image_maps['with_widths'])) {
                $tpl_map = ['HEIGHT' => $image_maps['with_widths'][0]['tpl_map']['HEIGHT']] + $tpl_map;
            }
            $_category->attach(do_template('FORM_SCREEN_INPUT_THEME_IMAGE_ENTRY', $tpl_map));
        }
        foreach ($image_maps['with_widths'] as $image_map) {
            $_category->attach(do_template('FORM_SCREEN_INPUT_THEME_IMAGE_ENTRY', $image_map['tpl_map']));
        }

        $_category = do_template('FORM_SCREEN_INPUT_THEME_IMAGE_CATEGORY', [
            '_GUID' => 'c2f429315b73bcaacc3bff8db11c0056',
            'DISPLAY' => $category_expanded ? 'block' : 'none',
            'FIELD_NAME' => $name,
            'CATEGORY' => $_category,
            'CATEGORY_NAME' => (count($categories) == 1) ? '' : $cat,
        ]);
        $content->attach($_category);
    }

    $input = do_template('FORM_SCREEN_INPUT_RADIO_LIST', [
        '_GUID' => '35fed772f022cf561f823543e56d63e8',
        'REQUIRED' => !$allow_none,
        'NAME' => $name,
        'CODE' => ($selected_code === null) ? '' : $selected_code,
        'TABINDEX' => strval($tabindex),
        'CONTENT' => $content,
        'IMAGES' => false, // Class applied in FORM_SCREEN_INPUT_THEME_IMAGE_CATEGORY instead
        'LINEAR' => $linear,
    ]);

    return _form_input($GLOBALS['DOING_ALTERNATE_FIELDS_SET'] ? $name : '', $pretty_name, $description, $input, !$allow_none);
}

/**
 * Get the Tempcode for a date input, or if the system scheduler is not running return blank.
 *
 * @param  mixed $pretty_name A human intelligible name for this input field, provided in plain-text format (string or Tempcode)
 * @param  mixed $description A description for this input field, provided in HTML format (string or Tempcode)
 * @param  ID_TEXT $name The parameter name for this input field
 * @param  boolean $required Whether this is a required field
 * @param  boolean $null_default Whether this field is empty by default
 * @param  boolean $do_time Whether to input time for this field also
 * @param  ?mixed $default_time The default timestamp to use (either TIME or array of time components) (null: none) [ignored if $null_default is set]
 * @param  ?integer $total_years_to_show The number of years to allow selection from (pass a negative number for selection of past years instead of future years) (null: no limit)
 * @param  ?integer $year_start The year to start counting $total_years_to_show from (null: if $total_years_to_show<0 then this year minus $total_years_to_show else if $total_years_to_show is not null then the current year else no restriction)
 * @param  ?integer $tabindex The tab index of the field (null: not specified)
 * @return Tempcode The input field
 */
function form_input_date__cron($pretty_name, $description, string $name, bool $required, bool $null_default, bool $do_time, $default_time = null, ?int $total_years_to_show = 10, ?int $year_start = null, ?int $tabindex = null) : object
{
    if (cron_installed()) {
        return form_input_date($pretty_name, $description, $name, $required, $null_default, $do_time, $default_time, $total_years_to_show, $year_start, $tabindex);
    }
    return new Tempcode();
}

/**
 * Get the Tempcode for a date input. Use post_param_date to read the result.
 *
 * @param  mixed $pretty_name A human intelligible name for this input field, provided in plain-text format (string or Tempcode)
 * @param  mixed $description A description for this input field, provided in HTML format (string or Tempcode)
 * @param  ID_TEXT $name The parameter name for this input field
 * @param  boolean $required Whether this is not a required field
 * @param  boolean $null_default Whether this field is empty by default
 * @param  boolean $do_time Whether to input time for this field also
 * @param  ?mixed $default_time The default timestamp to use (either TIME or array of time components) (null: none) [ignored if $null_default is set]
 * @param  ?integer $total_years_to_show The number of years to allow selection from (pass a negative number for selection of past years instead of future years) (null: no limit)
 * @param  ?integer $year_start The year to start counting $total_years_to_show from (null: if $total_years_to_show<0 then this year minus $total_years_to_show else if $total_years_to_show is not null then the current year else no restriction)
 * @param  ?integer $tabindex The tab index of the field (null: not specified)
 * @param  boolean $do_date Whether to input date for this field (if false, will just do time)
 * @param  ?ID_TEXT $timezone Timezone to input in (null: current user's timezone)
 * @param  boolean $handle_timezone Convert $default_time to $timezone
 * @param  ~?mixed $autocomplete The autocomplete field name. (false: explicitly disable autocomplete) (null: no autocomplete attribute unless there's a default for this $name)
 * @param  boolean $read_only Whether this field is disabled
 * @return Tempcode The input field
 */
function form_input_date($pretty_name, $description, string $name, bool $required, bool $null_default, bool $do_time, $default_time = null, ?int $total_years_to_show = 10, ?int $year_start = null, ?int $tabindex = null, bool $do_date = true, ?string $timezone = null, bool $handle_timezone = true, $autocomplete = null, bool $read_only = false) : object
{
    $input = _form_input_date($name, $required, $null_default, $do_time, $default_time, $total_years_to_show, $year_start, $tabindex, $do_date, $timezone, $handle_timezone, $autocomplete, $read_only);
    return _form_input($name, $pretty_name, $description, $input, $required, false, $tabindex);
}

/**
 * Get the Tempcode for a date input, raw.
 *
 * @param  ID_TEXT $name The parameter name for this input field
 * @param  boolean $required Whether this is a required field
 * @param  boolean $null_default Whether this field is empty by default
 * @param  boolean $do_time Whether to input time for this field also
 * @param  ?mixed $default_time The default timestamp to use (either TIME or array of time components) (null: none) [ignored if $null_default is set]
 * @param  ?integer $total_years_to_show The number of years to allow selection from (pass a negative number for selection of past years instead of future years) (null: no limit)
 * @param  ?integer $year_start The year to start counting $total_years_to_show from (null: if $total_years_to_show<0 then this year minus $total_years_to_show else if $total_years_to_show is not null then the current year else no restriction)
 * @param  ?integer $tabindex The tab index of the field (null: not specified)
 * @param  boolean $do_date Whether to input date for this field (if false, will just do time)
 * @param  ?ID_TEXT $timezone Timezone to input in (null: current user's timezone)
 * @param  boolean $handle_timezone Convert $default_time to $timezone
 * @param  ~?mixed $autocomplete The autocomplete field name. (false: explicitly disable autocomplete) (null: no autocomplete attribute unless there's a default for this $name)
 * @param  boolean $read_only Whether this field should be disabled
 * @return Tempcode The input field
 *
 * @ignore
 */
function _form_input_date(string $name, bool $required, bool $null_default, bool $do_time, $default_time = null, ?int $total_years_to_show = 10, ?int $year_start = null, ?int $tabindex = null, bool $do_date = true, ?string $timezone = null, bool $handle_timezone = true, $autocomplete = null, bool $read_only = false) : object
{
    $tabindex = get_form_field_tabindex($tabindex);

    $default_minute = null;
    $default_hour = null;
    $default_month = null;
    $default_day = null;
    $default_year = null;

    $required = filter_form_field_required($name, $required);

    if (is_array($default_time)) {
        if ($default_time[4] === null) {
            $default_time = null;
        } else {
            require_code('temporal');
            list($default_minute, $default_hour, $default_month, $default_day, $default_year) = $default_time;
            $default_time = cms_mktime($default_hour, $default_minute, 0, $default_month, $default_day, $default_year);
        }
    }

    $_default_time = filter_form_field_default($name, ($default_time === null) ? '' : strval($default_time));
    $default_time = ($_default_time == '') ? null : intval($_default_time);

    if (($default_time !== null) && ($handle_timezone)) {
        if ($timezone === null) {
            $timezone = get_users_timezone();
        }
        $default_time = tz_time($default_time, $timezone);
    }

    $default_minute = ($default_time === null) ? null : intval(date('i', $default_time));
    $default_hour = ($default_time === null) ? null : intval(date('H', $default_time));
    $default_day = ($default_time === null) ? null : intval(date('j', $default_time));
    $default_month = ($default_time === null) ? null : intval(date('n', $default_time));
    $default_year = ($default_time === null) ? null : intval(date('Y', $default_time));

    if (($total_years_to_show !== null) && ($year_start === null)) {
        if ($total_years_to_show < 0) {
            $year_start = intval(date('Y')) + $total_years_to_show;
        } else {
            $year_start = intval(date('Y'));
        }
    }
    // Fix if out of range
    if (($year_start !== null) && ($default_year !== null) && ($default_year < $year_start)) {
        if (($default_year < $year_start) && ($total_years_to_show !== null)) {
            $total_years_to_show += ($year_start - $default_year);
        }
        $year_start = $default_year;
    }

    $year_end = null;
    if (($total_years_to_show !== null) && ($year_start !== null)) {
        if ($total_years_to_show >= 0) {
            $year_end = $year_start + $total_years_to_show;
        } else {
            $year_end = $year_start - $total_years_to_show;
        }
    }
    // Fix if out of range
    if (($default_year !== null) && ($year_end !== null) && ($default_year > $year_end)) {
        $year_end = $default_year;
    }

    if ($null_default) {
        $default_minute = null;
        $default_hour = null;
        $default_day = null;
        $default_month = null;
        $default_year = null;
    }

    if ($do_date) {
        $type = $do_time ? 'datetime' : 'date';
    } else {
        $type = 'time';
    }

    $autocomplete = $do_date ? _get_autocomplete_attribute_value($name, $autocomplete) : null;

    return do_template($do_date ? 'FORM_SCREEN_INPUT_DATE' : 'FORM_SCREEN_INPUT_TIME', [
        '_GUID' => '5ace58dd0f540f70fb3bd440fb02a430',
        'REQUIRED' => $required,
        'TABINDEX' => strval($tabindex),
        'NAME' => $name,
        'TYPE' => $type,

        'YEAR' => ($default_year === null) ? '' : strval($default_year),
        'MONTH' => ($default_month === null) ? '' : strval($default_month),
        'DAY' => ($default_day === null) ? '' : strval($default_day),
        'HOUR' => ($default_hour === null) ? '' : strval($default_hour),
        'MINUTE' => ($default_minute === null) ? '' : strval($default_minute),

        'MIN_DATE_DAY' => ($year_start === null) ? '' : '1',
        'MIN_DATE_MONTH' => ($year_start === null) ? '' : '1',
        'MIN_DATE_YEAR' => ($year_start === null) ? '' : strval($year_start),
        'MAX_DATE_DAY' => ($year_end === null) ? '' : '31',
        'MAX_DATE_MONTH' => ($year_end === null) ? '' : '12',
        'MAX_DATE_YEAR' => ($year_end === null) ? '' : strval($year_end),

        'AUTOCOMPLETE' => $autocomplete,
        'READ_ONLY' => $read_only ? strval(1) : strval(0)
    ]);
}

/**
 * Get the Tempcode for a date component input.
 *
 * @param  mixed $pretty_name A human intelligible name for this input field, provided in plain-text format (string or Tempcode)
 * @param  mixed $description A description for this input field, provided in HTML format (string or Tempcode)
 * @param  ID_TEXT $name The name which this input field is for
 * @param  boolean $want_year Gather year
 * @param  boolean $want_month Gather month
 * @param  boolean $want_day Gather day
 * @param  integer $start_year Start year in selection range
 * @param  integer $end_year End year in selection range
 * @param  ?integer $default_year Default year (null: current year)
 * @param  ?integer $default_month Default month (null: current month)
 * @param  ?integer $default_day Default day (null: current day)
 * @param  boolean $required Whether this is a required input field
 * @param  ?integer $tabindex The tab index of the field (null: not specified)
 * @return Tempcode The input field
 */
function form_input_date_components($pretty_name, $description, string $name, bool $want_year, bool $want_month, bool $want_day, int $start_year, int $end_year, ?int $default_year, ?int $default_month, ?int $default_day, bool $required, ?int $tabindex = null) : object
{
    $tabindex = get_form_field_tabindex($tabindex);

    require_lang('dates');

    $required = filter_form_field_required($name, $required);

    $default_timestamp = tz_time(time(), get_users_timezone());

    $_required = ($required) ? '-required' : '';
    $input = do_template('FORM_SCREEN_INPUT_DATE_COMPONENTS', [
        '_GUID' => '8a75077992ed552dc9323183bb3c4e13',
        'REQUIRED' => $_required,
        'TABINDEX' => strval($tabindex),
        'NAME' => $name,
        'PRETTY_NAME' => $pretty_name,
        'WANT_YEAR' => $want_year,
        'START_YEAR' => strval(min($start_year, $default_year)),
        'END_YEAR' => strval(max($end_year, $default_year)),
        'YEAR' => ($default_year === null) ? date('Y', $default_timestamp) : strval($default_year),
        'WANT_MONTH' => $want_month,
        'MONTH' => ($default_month === null) ? date('m', $default_timestamp) : strval($default_month),
        'WANT_DAY' => $want_day,
        'DAY' => ($default_day === null) ? date('d', $default_timestamp) : strval($default_day),
    ]);
    return _form_input($name, $pretty_name, $description, $input, $required, false, $tabindex);
}

/**
 * Get the Tempcode for an integer-only input.
 *
 * @param  mixed $pretty_name A human intelligible name for this input field, provided in plain-text format (string or Tempcode)
 * @param  mixed $description A description for this input field, provided in HTML format (string or Tempcode)
 * @param  ID_TEXT $name The name which this input field is for
 * @param  ?integer $default The default value for this input field (null: no default)
 * @param  boolean $required Whether this is a required input field
 * @param  ?integer $tabindex The tab index of the field (null: not specified)
 * @param  ?integer $maxlength Maximum input length (null: no maximum length, regular HTML5 number input)
 * @param  ~?mixed $autocomplete The autocomplete field name. (false: explicitly disable autocomplete) (null: no autocomplete attribute unless there's a default for this $name)
 * @param  boolean $read_only Whether this field is disabled
 * @return Tempcode The input field
 */
function form_input_integer($pretty_name, $description, string $name, ?int $default, bool $required, ?int $tabindex = null, ?int $maxlength = null, $autocomplete = null, bool $read_only = false) : object
{
    $tabindex = get_form_field_tabindex($tabindex);

    $_default = filter_form_field_default($name, ($default === null) ? '' : strval($default));
    $default = ($_default == '') ? null : intval($_default);

    $required = filter_form_field_required($name, $required);

    $_required = ($required) ? '-required' : '';

    $autocomplete = _get_autocomplete_attribute_value($name, $autocomplete);

    $input = do_template('FORM_SCREEN_INPUT_INTEGER', [
        '_GUID' => 'da09e21f329f300f71dd4dd518cb6242',
        'TABINDEX' => strval($tabindex),
        'REQUIRED' => $_required,
        'NAME' => $name,
        'DEFAULT' => ($default === null) ? '' : strval($default),
        'MAXLENGTH' => ($maxlength === null) ? null : strval($maxlength),
        'AUTOCOMPLETE' => $autocomplete,
        'READ_ONLY' => $read_only
    ]);
    return _form_input($name, $pretty_name, $description, $input, $required, false, $tabindex);
}

/**
 * Get the Tempcode for an width/height input.
 *
 * @param  mixed $pretty_name A human intelligible name for this input field, provided in plain-text format (string or Tempcode)
 * @param  mixed $description A description for this input field, provided in HTML format (string or Tempcode)
 * @param  ID_TEXT $name_width The name which this input field is for: width part
 * @param  ID_TEXT $name_height The name which this input field is for: height part
 * @param  ?integer $default_width The default value for this input field: width part (null: no default)
 * @param  ?integer $default_height The default value for this input field: height part (null: no default)
 * @param  boolean $required Whether this is a required input field
 * @param  ?integer $tabindex The tab index of the field (null: not specified)
 * @return Tempcode The input field
 */
function form_input_dimensions($pretty_name, $description, string $name_width, string $name_height, ?int $default_width, ?int $default_height, bool $required, ?int $tabindex = null) : object
{
    $tabindex = get_form_field_tabindex($tabindex);

    $_default_width = filter_form_field_default($name_width, ($default_width === null) ? '' : strval($default_width));
    $default_width = ($_default_width == '') ? null : intval($_default_width);
    $_default_height = filter_form_field_default($name_height, ($default_height === null) ? '' : strval($default_height));
    $default_height = ($_default_height == '') ? null : intval($_default_height);

    $_required = ($required) ? '-required' : '';
    $input = do_template('FORM_SCREEN_INPUT_DIMENSIONS', [
        '_GUID' => 'd8ccbe6813e4d1a0c41a25adb87d5c35',
        'TABINDEX' => strval($tabindex),
        'REQUIRED' => $_required,
        'NAME_WIDTH' => $name_width,
        'DEFAULT_WIDTH' => ($default_width === null) ? '' : strval($default_width),
        'NAME_HEIGHT' => $name_height,
        'DEFAULT_HEIGHT' => ($default_height === null) ? '' : strval($default_height),
    ]);
    return _form_input($name_width, $pretty_name, $description, $input, $required, false, $tabindex);
}

/**
 * Get the Tempcode for a float-only input.
 *
 * @param  mixed $pretty_name A human intelligible name for this input field, provided in plain-text format (string or Tempcode)
 * @param  mixed $description A description for this input field, provided in HTML format (string or Tempcode)
 * @param  ID_TEXT $name The name which this input field is for
 * @param  ?float $default The default value for this input field (null: no default)
 * @param  boolean $required Whether this is a required input field
 * @param  ?integer $tabindex The tab index of the field (null: not specified)
 * @return Tempcode The input field
 */
function form_input_float($pretty_name, $description, string $name, ?float $default, bool $required, ?int $tabindex = null) : object
{
    $tabindex = get_form_field_tabindex($tabindex);

    $_default = filter_form_field_default($name, ($default === null) ? '' : strval($default));
    $default = ($_default == '') ? null : floatval($_default);

    $required = filter_form_field_required($name, $required);

    $_required = ($required) ? '-required' : '';
    $input = do_template('FORM_SCREEN_INPUT_FLOAT', [
        '_GUID' => '6db802ae840bfe7e87881f95c79133c4',
        'TABINDEX' => strval($tabindex),
        'REQUIRED' => $_required,
        'NAME' => $name,
        'DEFAULT' => ($default === null) ? '' : float_to_raw_string($default, 10, true),
    ]);
    return _form_input($name, $pretty_name, $description, $input, $required, false, $tabindex);
}

/**
 * Create a list entry for a timezone.
 *
 * @param  mixed $pretty_name A human intelligible name for this input field, provided in plain-text format (string or Tempcode)
 * @param  mixed $description A description for this input field, provided in HTML format (string or Tempcode)
 * @param  ID_TEXT $name The name which this input field is for
 * @param  string $default The selected value by default (blank: none)
 * @param  boolean $required Whether this is required
 * @param  integer $size How much space the list takes up (inline lists only)
 * @param  ~?mixed $autocomplete The autocomplete field name. (false: explicitly disable autocomplete) (null: no autocomplete attribute unless there's a default for this $name)
 * @return Tempcode The input field
 */
function form_input_timezone($pretty_name, $description, string $name, string $default = '', bool $required = true, int $size = 10, $autocomplete = null) : object
{
    require_code('caches');

    // Group timezones by region
    $timezones = get_cache_entry('timezone_list', serialize(['type' => 'grouped']));
    if ($timezones === null) {
        require_code('temporal');
        require_code('caches2');

        $timezones = [];
        foreach (get_timezone_list() as $timezone => $text) {
            if (strpos($timezone, '/') !== false) {
                $parts = explode('/', $timezone);
                $region = $parts[0];

                if (!isset($timezones[$region])) {
                    $timezones[$region] = [];
                }
                $timezones[$region][$timezone] = $text;
            } else {
                if (!isset($timezones['OTHER'])) {
                    $timezones['OTHER'] = [];
                }
                $timezones['OTHER'][$timezone] = $text;
            }
        }
        set_cache_entry('timezone_list', (60 * 24), serialize(['type' => 'grouped']), $timezones);
    }

    // Populate our list
    $list = new Tempcode();
    foreach ($timezones as $region => $entries) {
        $list_group = new Tempcode();
        foreach ($entries as $timezone => $text) {
            $list_group->attach(form_input_list_entry($timezone, $timezone == $default, $text));
        }
        if ($region == 'OTHER') { // FUDGE to prevent adding language strings to the cache
            $region = do_lang('OTHER');
        }
        $list->attach(form_input_list_group($region, $list_group));
    }

    unset($timezones);

    return form_input_list($pretty_name, $description, $name, $list, null, false, $required, null, $size, $autocomplete);
}

/**
 * Create a list entry for a mail template.
 * This will instead return a hidden field if only the default template exists.
 *
 * @param  mixed $pretty_name A human intelligible name for this input field, provided in plain-text format (string or Tempcode)
 * @param  mixed $description A description for this input field, provided in HTML format (string or Tempcode)
 * @param  ID_TEXT $name The name which this input field is for
 * @param  ?string $default The selected value by default (null: the configured default template)
 * @param  boolean $inline_list Whether this is an inline displayed list as opposed to a dropdown
 * @param  boolean $required Whether this is required
 * @return Tempcode The field
 */
function form_input_mail_template($pretty_name, $description, string $name, ?string $default, bool $inline_list = false, bool $required = true) : object
{
    if ($default === null) {
        $default = get_value('default_newsletter_mail_template', 'MAIL');
    }

    // Which newsletter template?
    $_template_choices = [];
    $tpl_paths = [
        get_custom_file_base() . '/themes/default/templates_custom',
        get_file_base() . '/themes/default/templates_custom',
        get_file_base() . '/themes/default/templates',
    ];
    foreach ($tpl_paths as $tpl_path) {
        $dh = @opendir($tpl_path);
        if ($dh !== false) {
            while (($f = readdir($dh)) !== false) {
                if (preg_match('#^MAIL.*\.tpl$#', $f) != 0) {
                    $tpl = basename($f, '.tpl');
                    $_template_choices[] = $tpl;
                }
            }
            closedir($dh);
        }
    }
    $_template_choices = array_unique($_template_choices);

    if ($_template_choices == [$default]) {
        return form_input_hidden($name, $default);
    }

    $template_choices = new Tempcode();
    foreach ($_template_choices as $tpl) {
        $template_choices->attach(form_input_list_entry($tpl, $default == $tpl, $tpl));
    }
    return form_input_list($pretty_name, $description, $name, $template_choices, null, $inline_list, $required);
}

/**
 * Start off a field set.
 *
 * IMPORTANT: Note that this function uses global state -- any fields generated between alternate_fields_set__start and alternate_fields_set__end will be rendered using field set templating.
 *
 * @param  ID_TEXT $set_name The codename for this field set
 * @return Tempcode Tempcode to start attaching the field set to
 */
function alternate_fields_set__start(string $set_name) : object
{
    global $DOING_ALTERNATE_FIELDS_SET;
    if ($DOING_ALTERNATE_FIELDS_SET !== null) {
        warn_exit(do_lang_tempcode('INTERNAL_ERROR'));
    }
    $DOING_ALTERNATE_FIELDS_SET = $set_name;
    return new Tempcode();
}

/**
 * Show a field set that has just been finished off.
 *
 * @param  ID_TEXT $set_name The codename for this field set (blank: just collect raw fields, no actual field set)
 * @param  mixed $pretty_name A human intelligible name for this input field, provided in plain-text format (string or Tempcode)
 * @param  mixed $description A description for this input field, provided in HTML format (string or Tempcode)
 * @param  Tempcode $fields The field set Tempcode
 * @param  boolean $required Whether it is required that this field set be filled in
 * @param  ?URLPATH $existing_image_preview_url Image URL to show, of the existing selection for this field (null: N/A) (blank: N/A)
 * @param  boolean $raw Whether we just want the raw set contents, without any wrapper field row
 * @param  string $default_set The default set to pre-select
 * @return Tempcode The field set
 */
function alternate_fields_set__end(string $set_name, $pretty_name, $description, object $fields, bool $required, ?string $existing_image_preview_url = null, bool $raw = false, string $default_set = '') : object
{
    global $DOING_ALTERNATE_FIELDS_SET;
    if ($DOING_ALTERNATE_FIELDS_SET === null) {
        return $fields; // Didn't actually start set, probably because some logic said not to - so just flow to append as normal
    }

    list($existing_image_preview_url, $is_image) = make_previewable_url_absolute($existing_image_preview_url);

    $set = do_template('FORM_SCREEN_FIELDS_SET', [
        '_GUID' => 'ae81cf68280aef067de1e8e71b2919a7',
        'FIELDS' => $fields,
        'PRETTY_NAME' => $pretty_name,
        'SET_NAME' => $set_name,
        'REQUIRED' => $required,
        'EXISTING_IMAGE_PREVIEW_URL' => $existing_image_preview_url,
        'DEFAULT_SET' => $default_set,
    ]);

    if ($DOING_ALTERNATE_FIELDS_SET === null) {
        warn_exit(do_lang_tempcode('INTERNAL_ERROR'));
    }
    $DOING_ALTERNATE_FIELDS_SET = null;

    if ($raw) {
        return $set;
    }

    return _form_input('', $pretty_name, $description, $set, $required);
}

/**
 * Start serving single field. This is used if you just need the raw widget, not the label (etc).
 */
function single_field__start()
{
    global $DOING_ALTERNATE_FIELDS_SET;
    $DOING_ALTERNATE_FIELDS_SET = '';
}

/**
 * Stop serving single field.
 */
function single_field__end()
{
    global $DOING_ALTERNATE_FIELDS_SET;
    $DOING_ALTERNATE_FIELDS_SET = null;
}

/**
 * Used to create a null option for field sets.
 *
 * @param  mixed $pretty_name A human intelligible name for this input field, provided in plain-text format (string or Tempcode)
 * @param  ?integer $tabindex The tab index (null: none specified)
 * @return Tempcode The field
 */
function form_input_na($pretty_name, ?int $tabindex = null) : object
{
    return _form_input('', $pretty_name, new Tempcode(), new Tempcode(), false, false, null);
}

/**
 * Helper function to show an input field.
 *
 * @param  ID_TEXT $name The codename for this field (blank: N/A)
 * @param  mixed $pretty_name A human intelligible name for this input field, provided in plain-text format (string or Tempcode)
 * @param  mixed $description A description for this input field, provided in HTML format (string or Tempcode)
 * @param  Tempcode $input The actual raw input field
 * @param  boolean $required Whether it is required that this field be filled in
 * @param  boolean $comcode Whether this field may contain Comcode
 * @param  ?integer $tabindex The tab index (null: none specified)
 * @param  boolean $w Whether it is a textarea field
 * @param  boolean $skip_label Whether to skip displaying a label for the field
 * @param  mixed $description_side A secondary side description for this input field, provided in HTML format (string or Tempcode)
 * @param  ?string $pattern_error Custom regex pattern validation error (null: none)
 * @return Tempcode The field
 *
 * @ignore
 */
function _form_input(string $name, $pretty_name, $description, object $input, bool $required, bool $comcode = false, ?int $tabindex = null, bool $w = false, bool $skip_label = false, $description_side = '', ?string $pattern_error = null) : object
{
    check_suhosin_request_quantity(2, ($name == '') ? 20 : strlen($name));

    if (($GLOBALS['DEV_MODE'])) {
        $discouraged_field_names = [
            // Keys are the discouraged field names, values are the encouraged field names.
            'email_address' => 'email',
            'date_of_birth' => 'birthday',
            'dob' => 'birthday',
            'street_address' => 'address1',
            'post_code' => 'postalcode',
            'shipping_street_address' => 'shipping_address1',
            'shipping_post_code' => 'shipping_postalcode',
            'billing_street_address' => 'billing_address1',
            'billing_post_code' => 'billing_postalcode',
        ];

        if (isset($discouraged_field_names[$name])) {
            fatal_exit(sprintf('Form field with $name "%s" is not allowed as it does not work reliably with autofill, use "%s" instead.', $name, $discouraged_field_names[$name]));
        }

        if (user_lang() == fallback_lang()) {
            $_description = trim(strip_tags(is_object($description) ? $description->evaluate() : $description));
            if (($_description != '') && (substr($_description, -1) != '.') && (substr(is_object($description) ? $description->evaluate() : $description, -6) != '</kbd>') && (substr($_description, -1) != '!') && (substr($_description, -1) != '?') && (substr($_description, -1) != ']') && (substr($_description, -1) != ')') && (!$GLOBALS['NO_DEV_MODE_FULLSTOP_CHECK'])) {
                fatal_exit('Form field $description parameters should end in full stops [' . $_description . '].');
            }
        }
    }

    // Return raw input field if field encapsulation is set to raw
    if (peek_field_encapsulation() == FIELD_ENCAPSULATION_RAW) {
        return make_string_tempcode($input->evaluate());
    }

    // Add prefixes and suffixes to input
    global $_FORM_INPUT_PREFIX, $_FORM_INPUT_SUFFIX;
    if ($_FORM_INPUT_PREFIX !== null) {
        $_input = new Tempcode();
        $_input->attach($_FORM_INPUT_PREFIX);
        $_input->attach('<br />');
        $_input->attach('<br />');
        $_input->attach($input);
        if ($_FORM_INPUT_SUFFIX !== null) {
            $_input->attach($_FORM_INPUT_SUFFIX);
        }
        $input = $_input;
    } elseif ($_FORM_INPUT_SUFFIX !== null) {
        $input->attach('<br />');
        $input->attach('<br />');
        $input->attach($_FORM_INPUT_SUFFIX);
    }

    $help_zone = get_comcode_zone('userguide_comcode', false);
    $_comcode = (($help_zone === null) || (!$comcode)) ? new Tempcode() : do_template('COMCODE_MESSAGE', ['_GUID' => '7668b8365e34b2484be7c2c271f82e79', 'NAME' => $name, 'W' => $w, 'URL' => build_url(['page' => 'userguide_comcode'], $help_zone)]);

    global $DOING_ALTERNATE_FIELDS_SET, $SKIPPING_LABELS;
    if ($DOING_ALTERNATE_FIELDS_SET !== null) {
        if ($DOING_ALTERNATE_FIELDS_SET == '') {
            return $input;
        }

        $tpl = do_template('FORM_SCREEN_FIELDS_SET_ITEM', [
            '_GUID' => '23f2e2df7fcacc01d9f5158dc635e73d',
            'SET_NAME' => $DOING_ALTERNATE_FIELDS_SET,
            'REQUIRED' => $required,
            'SKIP_LABEL' => $skip_label || $SKIPPING_LABELS,
            'NAME' => $name,
            'PRETTY_NAME' => $pretty_name,
            'DESCRIPTION' => $description,
            'DESCRIPTION_SIDE' => $description_side,
            'INPUT' => $input,
            'COMCODE' => $_comcode,
        ]);
        return $tpl;
    }

    $tpl = do_template('FORM_SCREEN_FIELD', [
        '_GUID' => 'fa1402b7ad8319372f4bb5b152be7852',
        'REQUIRED' => $required,
        'SKIP_LABEL' => $skip_label || $SKIPPING_LABELS,
        'NAME' => $name,
        'PRETTY_NAME' => $pretty_name,
        'DESCRIPTION' => $description,
        'DESCRIPTION_SIDE' => $description_side,
        'INPUT' => $input,
        'COMCODE' => $_comcode,
        'PATTERN_ERROR' => $pattern_error,
    ]);
    $tpl = make_string_tempcode($tpl->evaluate());
    return $tpl;
}

/**
 * Gets the autocomplete attribute value for a field.
 *
 * @param  ID_TEXT $name The input field [name] attribute value
 * @param  ~?mixed $provided_autocomplete The provided autocomplete value (false: explicitly disable autocomplete) (null: try to find a value for the provided $name, otherwise return null)
 * @return ?string (null: don't use an autocomplete attribute)
 */
function _get_autocomplete_attribute_value(string $name, $provided_autocomplete) : ?string
{
    $autocomplete_field_names = [
        // Keys ([name] attribute values) as agreed upon by "#0003470: Change our approach to autofill" https://composr.app/tracker/view.php?id=3470
        // Values ([autocomplete] attribute values) from https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofill-field
        'username'   => 'username',
        'password'   => 'current-password',
        'email'      => 'email',
        'firstname'  => 'given-name',
        'lastname'   => 'family-name',
        'name'       => 'name',
        'birthday'   => 'bday',
        'address1'   => 'street-address',
        'city'       => 'address-level2',
        'state'      => 'address-level1',
        'postalcode' => 'postal-code',
        'country'    => 'country',
        'phone'      => 'tel',
        'shipping_address1'   => 'shipping street-address',
        'shipping_city'       => 'shipping address-level2',
        'shipping_state'      => 'shipping address-level1',
        'shipping_postalcode' => 'shipping postal-code',
        'shipping_country'    => 'shipping country',
        'shipping_phone'      => 'shipping tel',
        'billing_address1'    => 'billing street-address',
        'billing_city'        => 'billing address-level2',
        'billing_state'       => 'billing address-level1',
        'billing_postalcode'  => 'billing postal-code',
        'billing_country'     => 'billing country',
        'billing_phone'       => 'billing tel',

    ];

    $autocomplete = $provided_autocomplete;

    if (isset($autocomplete_field_names[$name])) {
        if ($GLOBALS['DEV_MODE'] && isset($provided_autocomplete)) {
            if (!$provided_autocomplete) {
                fatal_exit(sprintf('Field with $name "%s" is reserved for autocomplete enabled fields and should not have it disabled.', $name));
            }

            if ($autocomplete_field_names[$name] !== $provided_autocomplete) {
                fatal_exit(sprintf('Field with $name "%s" must have autocomplete field name "%s" but "%s" was provided.', $name, $autocomplete_field_names[$name], $provided_autocomplete));
            }
        }

        $autocomplete = $autocomplete_field_names[$name];
    } elseif (($provided_autocomplete === false) || (($provided_autocomplete === null) && (starts_with($name, 'edit_')))) {
        $autocomplete = 'autocomplete-disabled';
    }

    return $autocomplete;
}

/**
 * Look for editing conflicts, and setup editing pinging.
 *
 * @param  ?ID_TEXT $id The ID we're editing (null: get from param, 'id')
 * @param  boolean $only_staff Whether to only care about staff conflicts
 * @return array A pair: warning details, ping URL
 */
function handle_conflict_resolution(?string $id = null, bool $only_staff = false) : array
{
    if (($only_staff) && (!$GLOBALS['FORUM_DRIVER']->is_staff(get_member()))) {
        return [null, null];
    }

    if (get_param_integer('refreshing', 0) == 1) {
        return [null, null];
    }

    if ($id === null) {
        $id = get_param_string('id', post_param_string('id', null));
        if ($id === null) {
            return [null, null];
        }
    }

    $last_edit_screen_time = $GLOBALS['SITE_DB']->query('SELECT * FROM ' . $GLOBALS['SITE_DB']->get_table_prefix() . 'edit_pings WHERE ' . db_string_equal_to('the_page', cms_mb_substr(get_page_name(), 0, 80)) . ' AND ' . db_string_equal_to('the_type', cms_mb_substr(get_param_string('type', 'browse'), 0, 80)) . ' AND ' . db_string_equal_to('the_id', cms_mb_substr($id, 0, 80)) . ' AND the_member<>' . strval(get_member()) . ' ORDER BY the_time DESC', 1);
    if ((array_key_exists(0, $last_edit_screen_time)) && ($last_edit_screen_time[0]['the_time'] > time() - 20)) {
        $username = $GLOBALS['FORUM_DRIVER']->get_username($last_edit_screen_time[0]['the_member']);
        $warning_details = do_template('WARNING_BOX', ['_GUID' => '10c4e7c0d16df68b38b66d162919c068', 'WARNING' => do_lang_tempcode('EDIT_CONFLICT_WARNING', escape_html($username))]);
    } else {
        $warning_details = null;
    }
    $keep = symbol_tempcode('KEEP');
    $ping_url = find_script('edit_ping') . '?page=' . urlencode(get_page_name()) . '&type=' . urlencode(get_param_string('type', 'browse')) . '&id=' . urlencode($id) . $keep->evaluate();

    return [$warning_details, $ping_url];
}

/**
 * Helper function for tab-index linearisation (serves as a filter).
 *
 * @param  ?integer $tabindex Requested tab-index (null: no specific request)
 * @return integer Used tab-index
 */
function get_form_field_tabindex(?int $tabindex = null) : int
{
    if ($tabindex === null) {
        global $TABINDEX;
        $tabindex = $TABINDEX;
        $TABINDEX++;
    }
    return $tabindex;
}

/**
 * Put in default text to a Comcode field, if it's not pre-populated with anything.
 *
 * @param  ?string $default Default value (null: not set) (blank: not set)
 */
function handle_default_comcode_text(?string &$default)
{
    if (cms_empty_safe($default)) {
        $_dct = get_value('default_comcode_text', '');
        if ($_dct != '') {
            $default = str_replace('{content}', '', $_dct);
        }
    }
}

/**
 * Adjust quick replies to be wrapped with the default_comcode_text hidden option (if applicable).
 *
 * @param  string $post Post value
 */
function handle_default_comcode_text_input(string &$post)
{
    if ($post != '') {
        if (post_param_integer('_comment_form_post', 0) == 1) {
            $_dct = get_value('default_comcode_text', '');
            if (($_dct != '') && (strpos($_dct, '{content}') !== false)) {
                $post = str_replace('{content}', $post, $_dct);
            }
        }
    }
}

/**
 * Place a global marker as to what field encapsulation to use.
 *
 * @param  integer $setting Temporary setting (see FIELD_ENCAPSULATION_*)
 */
function push_field_encapsulation(int $setting)
{
    global $FIELD_ENCAPSULATION_STACK;
    array_push($FIELD_ENCAPSULATION_STACK, $setting);
}

/**
 * Go back to the previous field encapsulation global marker.
 */
function pop_field_encapsulation()
{
    global $FIELD_ENCAPSULATION_STACK;
    array_pop($FIELD_ENCAPSULATION_STACK);
}

/**
 * Get which field encapsulation marker is current.
 *
 * @return integer FIELD_ENCAPSULATION_*
 */
function peek_field_encapsulation() : int
{
    global $FIELD_ENCAPSULATION_STACK;
    return end($FIELD_ENCAPSULATION_STACK);
}
