<?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_fields
 */

/*EXTRA FUNCTIONS: get_nested_spreadsheet_structure*/

/**
 * Farm out the files for catalogue entry fields.
 */
function catalogue_file_script()
{
    // Closed site
    $site_closed = get_option('site_closed');
    if (($site_closed != '0') && (!has_privilege(get_member(), 'access_closed_site')) && (!$GLOBALS['IS_ACTUALLY_ADMIN'])) {
        http_response_code(503);
        header('Content-Type: text/plain; charset=' . get_charset());
        @exit(get_option('closed'));
    }

    $table = get_param_string('table');

    // Security check; doesn't work for very old attachments (pre-v8) LEGACY note
    if ($table != 'catalogue_efv_short' && $table != 'catalogue_efv_long' && $table != 'f_member_custom_fields') { // FUDGE
        warn_exit(do_lang_tempcode('INTERNAL_ERROR'));
    }

    $original_filename = get_param_string('original_filename', null, INPUT_FILTER_GET_COMPLEX);
    $is_catalogue_type = ($table == 'catalogue_efv_short' || $table == 'catalogue_efv_long');
    $is_member_type = ($table == 'f_member_custom_fields');

    $file = filter_naughty(get_param_string('file', false, INPUT_FILTER_GET_COMPLEX));

    $entry_id = get_param_integer('id');
    $field_id = get_param_integer('field_id', null);
    $id_field = filter_naughty_harsh(get_param_string('id_field'));
    $field_id_field = get_param_string('field_id_field', null);
    if ($field_id_field !== null) {
        $field_id_field = filter_naughty_harsh($field_id_field);
    }
    $url_field = filter_naughty_harsh(get_param_string('url_field'));
    $ev = basename($file);
    if ($original_filename === null) {
        $original_filename = basename($file);
    }
    $where = [$id_field => $entry_id];
    if ($field_id_field !== null) {
        $where[$field_id_field] = $field_id;
    }
    $_ev_check = $GLOBALS['SITE_DB']->query_select_value_if_there($table, $url_field, $where, '', true);
    if ($_ev_check === null) {
        warn_exit(do_lang_tempcode('MISSING_RESOURCE'));
    }

    $ev_checks = explode("\n", $_ev_check);

    // Check if the file exists in the field
    $ev_path = null;
    foreach ($ev_checks as $ev_item) {
        $ev_check = explode('::', $ev_item);
        if (basename($ev_check[0]) == $ev) {
            $ev_path = $ev_item;
            break;
        }
    }
    if ($ev_path === null) {
        warn_exit(do_lang_tempcode('MISSING_RESOURCE', do_lang_tempcode('FILE')));
    }

    // Prepare the file
    $_full_path = get_custom_file_base() . '/' . $ev_path;
    $_path_bits = explode('::', $_full_path);
    if (!file_exists($_path_bits[0])) {
        warn_exit(do_lang_tempcode('MISSING_RESOURCE', do_lang_tempcode('FILE')));
    }
    $_full = $_path_bits[0];
    $size = filesize($_full);

    if ($is_catalogue_type) { // Now check the match, if we support checking on it
        if (!is_our_server()/*We need to allow media renderer to get through*/) {
            $c_name = $GLOBALS['SITE_DB']->query_select_value('catalogue_entries', 'c_name', ['id' => $entry_id]);
            if (substr($c_name, 0, 1) != '_') { // Doesn't work on custom fields (this is documented)
                $cc_id = $GLOBALS['SITE_DB']->query_select_value('catalogue_entries', 'cc_id', ['id' => $entry_id]);
                if (!has_category_access(get_member(), 'catalogues_catalogue', $c_name)) {
                    access_denied('CATALOGUE_ACCESS');
                }
                if (!has_category_access(get_member(), 'catalogues_category', strval($cc_id))) {
                    access_denied('CATEGORY_ACCESS');
                }
            }
            if (addon_installed('content_privacy')) {
                require_code('content_privacy');
                check_privacy('catalogue_entry', strval($entry_id));
            }
        }
    }

    // Find submitter
    if ($is_catalogue_type) {
        $submitter = $GLOBALS['SITE_DB']->query_select_value('catalogue_entries', 'ce_submitter', ['id' => $entry_id]);
    } elseif ($is_member_type) {
        $submitter = $entry_id;
    } else {
        $submitter = null;
    }

    // Caching
    set_http_caching(filemtime($_full));

    // Send header
    require_code('mime_types');
    header('Content-Type: ' . get_mime_type(get_file_extension($original_filename), false));
    if ($original_filename !== null) {
        $original_filename = filter_naughty($original_filename);

        if ((strpos($original_filename, "\n") !== false) || (strpos($original_filename, "\r") !== false)) {
            log_hack_attack_and_exit('HEADER_SPLIT_HACK');
        }
        if (get_option('immediate_downloads', true) === '1' || get_param_integer('inline', 0) == 1) {
            require_code('mime_types');
            $as_admin = ($submitter !== null) && (has_privilege($submitter, 'comcode_dangerous'));
            header('Content-Type: ' . get_mime_type(get_file_extension($original_filename), $as_admin));
            header('Content-Disposition: inline; filename="' . escape_header($original_filename, true) . '"');
        } else {
            header('Content-Disposition: attachment; filename="' . escape_header($original_filename, true) . '"');
        }
    } else {
        header('Content-Disposition: attachment');
    }
    header('Accept-Ranges: bytes');
    cms_ini_set('ocproducts.xss_detect', '0');

    // Default to no resume
    $from = 0;
    $new_length = $size;

    disable_output_compression(); // So ranges work, plus workaround to bugs caused by IE being 'smart' http://blogs.msdn.com/b/ieinternals/archive/2014/10/21/http-compression-optimize-file-formats-with-deflate.aspx

    // They're trying to resume (so update our range)
    $httprange = $_SERVER['HTTP_RANGE'];
    if (strlen($httprange) > 0) {
        $_range = explode('=', $_SERVER['HTTP_RANGE']);
        if (count($_range) == 2) {
            if (strpos($_range[0], '-') === false) {
                $_range = array_reverse($_range);
            }
            $range = $_range[0];
            if (substr($range, 0, 1) == '-') {
                $range = strval($size - intval(substr($range, 1)) - 1) . $range;
            }
            if (substr($range, -1, 1) == '-') {
                $range .= strval($size - 1);
            }
            $bits = explode('-', $range);
            if (count($bits) == 2) {
                list($from, $to) = array_map('intval', $bits);
                if (($to - $from != 0) || ($from == 0)) {
                    $new_length = $to - $from + 1;

                    header('HTTP/1.1 206 Partial Content');
                    header('Content-Range: bytes ' . $range . '/' . strval($size));
                } else {
                    $from = 0;
                }
            }
        }
    }
    header('Content-Length: ' . strval($new_length));
    cms_disable_time_limit();
    error_reporting(0);
    cms_ob_end_clean();

    if ($_SERVER['REQUEST_METHOD'] == 'HEAD') {
        return;
    }

    // Send actual data
    $myfile = fopen($_full, 'rb');
    fseek($myfile, $from);
    if ($size == $new_length) {
        fpassthru($myfile);
    } else {
        $i = 0;
        while ($i < $new_length) {
            $content = fread($myfile, min($new_length - $i, 1048576));
            echo $content;
            $len = strlen($content);
            if ($len == 0) {
                break;
            }
            $i += $len;
        }
    }
    @fclose($myfile);
}

/**
 * Create a custom field programmatically, or return existing if exists.
 * This function is intended for programmers, writing upgrade scripts for a custom site (dev>staging>live).
 *
 * @param  string $name Field name
 * @param  string $description Field description
 * @param  integer $order Field order
 * @param  string $content_type Content type
 * @return AUTO_LINK Field ID
 */
function define_custom_field(string $name, string $description = '', int $order = 0, string $content_type = 'comcode_page') : int
{
    $settings_map = [
        'c_name' => '_' . $content_type,
        'cf_put_in_category' => 1,
        'cf_order' => $order,
        'cf_type' => 'short_text',
        'cf_required' => 0,
        'cf_is_sortable' => 0,
        'cf_include_in_main_search' => 0,
        'cf_allow_template_search' => 0,
        'cf_defines_order' => 0,
        'cf_visible' => 1,
        'cf_sensitive' => 0,
        'cf_put_in_search' => 0,
        'cf_default' => '',
    ];

    $db = $GLOBALS['SITE_DB'];

    $field_details = $db->query_select('catalogue_fields', 'id', [$db->translate_field_ref('cf_name') => $name, 'c_name' => '_' . $content_type], '', 1);
    if (empty($field_details)) {
        $settings_map += insert_lang('cf_name', $name, 2);
        $settings_map += insert_lang('cf_description', $description, 2);
        $field_id = $db->query_insert('catalogue_fields', $settings_map);
    } else {
        $field_id = $field_details[0]['id'];
        $settings_map += lang_remap('cf_name', $field_details[0]['cf_name'], $name);
        $settings_map += lang_remap_comcode('cf_description', $field_details[0]['cf_description'], $description);
        $db->query_update('catalogue_fields', $settings_map, ['id' => $field_id], '', 1);

        $db->query_delete('catalogue_efv_short', ['cf_id' => $field_id]);
    }

    return $field_id;
}

/**
 * Parse field options into a setting map and return a specific value from it.
 *
 * @param  array $field Field map
 * @param  string $name Field name
 * @param  string $default Field default value
 * @return string The value
 */
function option_value_from_field_array(array $field, string $name, string $default = '') : string
{
    if (empty($field['cf_options'])) {
        $options = [];
    } else {
        $options = parse_field_options($field['cf_options']);
    }
    if (@cms_empty_safe($options[$name])) {
        return $default;
    }
    return $options[$name];
}

/**
 * Parse a field options string into a setting map.
 * Based on the code from comma_list_str_to_arr, with some modifications.
 *
 * @param  string $str Options string
 * @return array The setting map
 */
function parse_field_options(string $str) : array
{
    if ($str == '') {
        return [];
    }

    $map = [];
    $len = strlen($str);
    $escaped = false;
    $in_key = true;
    $key = '';
    $val = '';
    for ($i = 0; $i < $len; $i++) {
        $c = $str[$i];
        if ($in_key) {
            if ($c == ',') {
                $map[$key] = 'on';
                continue;
            } elseif ($c == '=') {
                $in_key = false;
            } else {
                $key .= $c;
            }
        } else {
            if ($escaped) {
                $escaped = false;
                $val .= $c;
            } else {
                if ($c == '\\') {
                    $escaped = true;
                } elseif ($c == ',') {
                    $in_key = true;
                    $map[$key] = $val;
                    $key = '';
                    $val = '';
                } else {
                    $val .= $c;
                }
            }
        }
    }
    if ($in_key) {
        $map[$key] = 'on';
    } else {
        $map[$key] = $val;
    }

    ksort($map);

    return $map;
}

/**
 * Ensure a catalogues fields are loaded up in a cache, and return them.
 *
 * @param  ?ID_TEXT $catalogue_name The name of the catalogue (null: all catalogues)
 * @return array The fields (empty array if the catalogue does not exist)
 */
function get_catalogue_fields(?string $catalogue_name = null) : array
{
    global $CAT_FIELDS_CACHE;
    if (isset($CAT_FIELDS_CACHE[$catalogue_name])) {
        $fields = $CAT_FIELDS_CACHE[$catalogue_name];
    } else {
        $where = [];
        if ($catalogue_name !== null) {
            $where += ['c_name' => $catalogue_name];
        }
        $fields = $GLOBALS['SITE_DB']->query_select('catalogue_fields', ['*'], $where, 'ORDER BY cf_order,' . $GLOBALS['SITE_DB']->translate_field_ref('cf_name'));
        $CAT_FIELDS_CACHE[$catalogue_name] = $fields;
    }
    return $fields;
}

/**
 * Get a fields hook, from a given codename.
 *
 * @param  ID_TEXT $type Codename
 * @return object Hook object
 */
function get_fields_hook(string $type) : object
{
    static $fields_hook_cache = [];
    if (isset($fields_hook_cache[$type])) {
        return $fields_hook_cache[$type];
    }

    // Try loading the field hook directly
    $ob = get_hook_ob('systems', 'fields', filter_naughty($type), 'Hook_fields_', true);
    if (is_object($ob)) {
        $fields_hook_cache[$type] = $ob;
        return $ob;
    }

    // Try finding the correct hook through get_field_types
    $hooks = find_all_hook_obs('systems', 'fields', 'Hook_fields_');
    foreach ($hooks as $ob) {
        if (method_exists($ob, 'get_field_types')) {
            if (array_key_exists($type, $ob->get_field_types($type))) {
                $fields_hook_cache[$type] = $ob;
                return $ob;
            }
        }
    }

    // Try creating an object factory as a last resort
    require_code('failure');
    $path = 'hooks/systems/fields/' . filter_naughty($type);
    set_throw_errors(true);
    try {
        require_code($path);
        $ob = object_factory('Hook_fields_' . filter_naughty($type));
        if (is_object($ob)) {
            $fields_hook_cache[$type] = $ob;
            return $ob;
        }
    } catch (CMSException $e) {
        $ob = null;
    }
    set_throw_errors(false);

    // We got nothing; fall back to short_text
    if ($type == 'short_text') { // This should never happen where even short_text fails!
        fatal_exit(do_lang_tempcode('INTERNAL_ERROR'));
    }
    return get_fields_hook('short_text');
}

/**
 * Get extra do-next icon for managing custom fields for a content type.
 *
 * @param  ID_TEXT $content_type Content type hook codename
 * @return array Extra do-next icon (single item array, or empty array if catalogues not installed)
 */
function manage_custom_fields_donext_link(string $content_type) : array
{
    if (addon_installed('catalogues')) {
        require_lang('fields');

        require_code('content');
        $ob = get_content_object($content_type);
        $info = $ob->info();

        if (($info !== null) && ($info['support_custom_fields']) && (has_privilege(get_member(), 'submit_cat_highrange_content', 'cms_catalogues')) && (has_privilege(get_member(), 'edit_cat_highrange_content', 'cms_catalogues'))) {
            static $count = [];
            if (!isset($count[$content_type])) {
                $count[$content_type] = $GLOBALS['SITE_DB']->query_select_value('catalogue_fields', 'COUNT(*)', ['c_name' => '_' . $content_type]);
            }
            $exists = ($count[$content_type] != 0);

            return [
                ['menu/cms/catalogues/edit_one_catalogue', ['cms_catalogues', ['type' => $exists ? '_edit_catalogue' : 'add_catalogue', 'id' => '_' . $content_type, 'redirect' => protect_url_parameter(SELF_REDIRECT)], get_module_zone('cms_catalogues')], do_lang('EDIT_CUSTOM_FIELDS', do_lang($info['content_type_label']))],
            ];
        }
    }

    return [];
}

/**
 * Get extra entry point data for managing custom fields for a content type.
 *
 * @param  ID_TEXT $content_type Content type hook codename
 * @return array Extra get_entry_points data
 */
function manage_custom_fields_entry_points(string $content_type) : array
{
    if (addon_installed('catalogues')) {
        require_lang('fields');

        require_code('content');
        $ob = get_content_object($content_type);
        $info = $ob->info();

        if (($info !== null) && ($info['support_custom_fields']) && (has_privilege(get_member(), 'submit_cat_highrange_content', 'cms_catalogues')) && (has_privilege(get_member(), 'edit_cat_highrange_content', 'cms_catalogues'))) {
            static $count = [];
            if (!isset($count[$content_type])) {
                $count[$content_type] = $GLOBALS['SITE_DB']->query_select_value('catalogue_fields', 'COUNT(*)', ['c_name' => '_' . $content_type]);
            }
            $exists = ($count[$content_type] != 0);

            return [
                '_SEARCH:cms_catalogues:' . ($exists ? '_edit_catalogue' : 'add_catalogue') . ':_' . $content_type => [
                    do_lang_tempcode('menus:ITEMS_HERE', do_lang_tempcode('EDIT_CUSTOM_FIELDS', do_lang($info['content_type_label'])), make_string_tempcode(escape_html(integer_format($count[$content_type], 0)))),
                    'menu/cms/catalogues/edit_one_catalogue',
                ],
            ];
        }
    }

    return [];
}

/**
 * Find whether a content type has a tied catalogue.
 *
 * @param  ID_TEXT $content_type Content type hook codename
 * @return boolean Whether it has
 */
function has_tied_catalogue(string $content_type) : bool
{
    if (addon_installed('catalogues')) {
        require_code('content');
        $ob = get_content_object($content_type);
        $info = $ob->info();
        if (($info !== null) && (array_key_exists('support_custom_fields', $info)) && ($info['support_custom_fields'])) {
            $exists = ($GLOBALS['SITE_DB']->query_select_value_if_there('catalogues', 'c_name', ['c_name' => '_' . $content_type]) !== null);
            if ($exists) {
                $first_cat = $GLOBALS['SITE_DB']->query_select_value_if_there('catalogue_categories', 'MIN(id)', ['c_name' => '_' . $content_type]);
                if ($first_cat === null) { // Repair needed, must have a category
                    require_code('catalogues2');
                    require_lang('catalogues');
                    actual_add_catalogue_category('_' . $content_type, do_lang('CUSTOM_FIELDS_FOR', do_lang($info['content_type_label'])), '', '', null);
                }

                return true;
            }
        }
    }
    return false;
}

/**
 * Get catalogue entry ID bound to a content entry, with auto-creation.
 *
 * @param  ID_TEXT $content_type Content type hook codename
 * @param  ID_TEXT $id Content entry ID
 * @return ?AUTO_LINK Bound catalogue entry ID (null: none)
 */
function get_bound_content_entry_wrap(string $content_type, string $id) : ?int
{
    $catalogue_entry_id = get_bound_content_entry($content_type, $id);

    if ($catalogue_entry_id === null) {
        require_code('catalogues2');

        $first_cat = $GLOBALS['SITE_DB']->query_select_value('catalogue_categories', 'MIN(id)', ['c_name' => '_' . $content_type]);
        if ($first_cat === null) {
            $first_cat = actual_add_catalogue_category('_' . $content_type, do_lang('DEFAULT'), '', '', null);
        }

        $catalogue_entry_id = actual_add_catalogue_entry($first_cat, 1, '', 0, 0, 0, []);

        $GLOBALS['SITE_DB']->query_insert('catalogue_entry_linkage', [
            'catalogue_entry_id' => $catalogue_entry_id,
            'content_type' => $content_type,
            'content_id' => $id,
        ]);
    }

    return $catalogue_entry_id;
}

/**
 * Get catalogue entry ID bound to a content entry.
 *
 * @param  ID_TEXT $content_type Content type hook codename
 * @param  ID_TEXT $id Content entry ID
 * @return ?AUTO_LINK Bound catalogue entry ID (null: none)
 */
function get_bound_content_entry(string $content_type, string $id) : ?int
{
    if (!addon_installed('catalogues')) {
        return null;
    }

    // Optimisation: don't keep up looking custom field linkage if we have no custom fields
    static $content_type_has_custom_fields_cache = null;
    if ($content_type_has_custom_fields_cache === null) {
        $content_type_has_custom_fields_cache = persistent_cache_get('CONTENT_TYPE_HAS_CUSTOM_FIELDS_CACHE');
    }
    if ($content_type_has_custom_fields_cache === null) {
        $content_type_has_custom_fields_cache = [];
    }
    if (!array_key_exists($content_type, $content_type_has_custom_fields_cache)) {
        $content_type_has_custom_fields_cache[$content_type] = ($GLOBALS['SITE_DB']->query_select_value_if_there('catalogue_fields', 'id', [
            'c_name' => '_' . $content_type,
        ]) !== null);
        persistent_cache_set('CONTENT_TYPE_HAS_CUSTOM_FIELDS_CACHE', $content_type_has_custom_fields_cache);
    }
    if (!$content_type_has_custom_fields_cache[$content_type]) {
        return null;
    }
    static $get_bound_content_entry_cache = null;
    if (!isset($get_bound_content_entry_cache[$content_type . ':' . $id])) {
        $ret = $GLOBALS['SITE_DB']->query_select_value_if_there('catalogue_entry_linkage', 'catalogue_entry_id', [
            'content_type' => $content_type,
            'content_id' => $id,
        ]);

        if ((in_array(get_zone_name(), ['cms', 'adminzone'])) && ($ret !== null)) {
            // Extra testing, possible corruption
            $test = $GLOBALS['SITE_DB']->query_select_value_if_there('catalogue_entries', 'id', ['id' => $ret]);
            if ($test === null) {
                $ret = null;

                $GLOBALS['SITE_DB']->query_delete('catalogue_entry_linkage', [
                    'content_type' => $content_type,
                    'content_id' => $id,
                ]);
            }
        }

        $get_bound_content_entry_cache[$content_type . ':' . $id] = $ret;
    } else {
        $ret = $get_bound_content_entry_cache[$content_type . ':' . $id];
    }
    return $ret;
}

/**
 * Append fields to content add/edit form for gathering custom fields.
 *
 * @param  ID_TEXT $content_type Content type hook codename
 * @param  ?ID_TEXT $id Content entry ID (null: new entry)
 * @param  Tempcode $fields Fields (passed by reference)
 * @param  Tempcode $hidden Hidden Fields (passed by reference)
 * @param  ?array $field_filter Limit fields to a set (null: no limit)
 * @param  boolean $field_filter_inclusion_list Whether $field_filter is a inclusion list (if false, it is a exclusion-list)
 * @param  boolean $add_separate_header Whether to add a separate header above the fields, so long as not all the fields are already under some other header
 */
function append_form_custom_fields(string $content_type, ?string $id, object &$fields, object &$hidden, ?array $field_filter = null, bool $field_filter_inclusion_list = true, bool $add_separate_header = false)
{
    if (!addon_installed('catalogues')) {
        return;
    }

    require_code('catalogues');

    $catalogue_entry_id = ($id === null) ? null : get_bound_content_entry($content_type, $id);
    if ($catalogue_entry_id !== null) {
        $special_fields = get_catalogue_entry_field_values('_' . $content_type, $catalogue_entry_id);
    } else {
        $special_fields = $GLOBALS['SITE_DB']->query_select('catalogue_fields', ['*'], ['c_name' => '_' . $content_type], 'ORDER BY cf_order,' . $GLOBALS['SITE_DB']->translate_field_ref('cf_name'));
    }

    $field_groups = [];

    require_code('fields');
    foreach ($special_fields as $field_num => $field) {
        if (($field_filter !== null) && ($field_filter_inclusion_list) && (!in_array($field['id'], $field_filter))) {
            continue;
        }
        if (($field_filter !== null) && (!$field_filter_inclusion_list) && (in_array($field['id'], $field_filter))) {
            continue;
        }

        $ob = get_fields_hook($field['cf_type']);
        $default = get_param_string('field_' . strval($field['id']), array_key_exists('effective_value_pure', $field) ? $field['effective_value_pure'] : $field['cf_default'], INPUT_FILTER_GET_COMPLEX);

        $_cf_name = get_translated_text($field['cf_name']);
        $field_cat = '';
        $matches = [];
        if (strpos($_cf_name, ': ') !== false) {
            $field_cat = substr($_cf_name, 0, strpos($_cf_name, ': '));
            if ($field_cat . ': ' == $_cf_name) {
                $_cf_name = $field_cat; // Just been pulled out as heading, nothing after ": "
            } else {
                $_cf_name = substr($_cf_name, strpos($_cf_name, ': ') + 2);
            }
        }
        if (!array_key_exists($field_cat, $field_groups)) {
            $field_groups[$field_cat] = new Tempcode();
        }

        $__cf_description = get_translated_text($field['cf_description']);
        global $LANG_FILTER_OB;
        $__cf_description = $LANG_FILTER_OB->compile_time(null, $__cf_description); // So that eCommerce catalogue fields referencing config options look better
        $_cf_description = escape_html($__cf_description);

        $GLOBALS['NO_DEV_MODE_FULLSTOP_CHECK'] = true;
        $result = $ob->get_field_inputter($_cf_name, $_cf_description, $field, $default, true, !array_key_exists($field_num + 1, $special_fields));
        $GLOBALS['NO_DEV_MODE_FULLSTOP_CHECK'] = false;

        if ($result === null) {
            continue;
        }

        if (is_array($result)) {
            $field_groups[$field_cat]->attach($result[0]);
            $hidden->attach($result[1]);
        } else {
            $field_groups[$field_cat]->attach($result);
        }

        unset($result);
        unset($ob);
    }

    if (array_key_exists('', $field_groups)) { // Blank prefix must go first
        $field_groups_blank = $field_groups[''];
        unset($field_groups['']);
        $field_groups = array_merge([$field_groups_blank], $field_groups);
    }

    if ($add_separate_header) {
        $fields->attach(do_template('FORM_SCREEN_FIELD_SPACER', ['_GUID' => '9ebf9c2c66923907b561364c37224728', 'TITLE' => do_lang_tempcode('MORE')]));
    }
    foreach ($field_groups as $field_group_title => $extra_fields) {
        if (is_integer($field_group_title)) {
            $field_group_title = ($field_group_title == 0) ? '' : strval($field_group_title);
        }

        if ($field_group_title != '') {
            $fields->attach(do_template('FORM_SCREEN_FIELD_SPACER', ['_GUID' => '58937f03882cc09276fa100933eb6041', 'TITLE' => $field_group_title]));
        }
        $fields->attach($extra_fields);
    }
}

/**
 * Save custom fields to a content item.
 *
 * @param  ID_TEXT $content_type Content type hook codename
 * @param  ID_TEXT $id Content entry ID
 * @param  ?ID_TEXT $old_id Content entry ID (prior to possible rename) (null: definitely unchanged)
 */
function save_form_custom_fields(string $content_type, string $id, ?string $old_id = null)
{
    if (!addon_installed('catalogues')) {
        return;
    }

    if (fractional_edit()) {
        return;
    }

    if ($old_id === null) {
        $old_id = $id;
    }

    $existing = get_bound_content_entry($content_type, $old_id);

    require_code('catalogues');

    // Check there is actually a catalogue here (technically we could avoid this because we return if there are zero catalogue_fields rows, but there could be corruption with those rows but no catalogue)
    $test = $GLOBALS['SITE_DB']->query_select_value_if_there('catalogues', 'c_name', ['c_name' => '_' . $content_type]);
    if ($test === null) {
        return;
    }

    // Get field values
    $fields = $GLOBALS['SITE_DB']->query_select('catalogue_fields', ['*'], ['c_name' => '_' . $content_type], 'ORDER BY cf_order,' . $GLOBALS['SITE_DB']->translate_field_ref('cf_name'));
    $map = [];
    require_code('fields');
    foreach ($fields as $field) {
        $ob = get_fields_hook($field['cf_type']);

        list(, , $storage_type) = $ob->get_field_value_row_bits($field);

        $value = $ob->inputted_to_field_value($existing !== null, $field, 'uploads/catalogues', ($existing === null) ? null : _get_catalogue_entry_field($field['id'], $existing, $storage_type));

        // Required field validation (a standard for all field hooks except tick)
        if (($field['cf_type'] != 'tick') && ($field['cf_required'] == 1) && (($value == '') || ($value === null) || (($value == STRING_MAGIC_NULL) && !fractional_edit()))) {
            warn_exit(do_lang_tempcode('_REQUIRED_NOT_FILLED_IN', get_translated_tempcode('catalogue_fields', $field, 'cf_name')));
        }

        $map[$field['id']] = $value;
    }
    if (empty($fields)) {
        return;
    }

    $first_cat = $GLOBALS['SITE_DB']->query_select_value('catalogue_categories', 'MIN(id)', ['c_name' => '_' . $content_type]);
    if ($first_cat === null) {
        require_code('catalogues2');
        $first_cat = actual_add_catalogue_category('_' . $content_type, do_lang('DEFAULT'), '', '', null);
    }

    require_code('catalogues2');

    if ($existing !== null) {
        actual_edit_catalogue_entry($existing, $first_cat, 1, '', 0, 0, 0, $map);
    } else {
        $catalogue_entry_id = actual_add_catalogue_entry($first_cat, 1, '', 0, 0, 0, $map);

        $GLOBALS['SITE_DB']->query_insert('catalogue_entry_linkage', [
            'catalogue_entry_id' => $catalogue_entry_id,
            'content_type' => $content_type,
            'content_id' => $id,
        ]);
    }
}

/**
 * Delete custom fields for content item.
 *
 * @param  ID_TEXT $content_type Content type hook codename
 * @param  ID_TEXT $id Content entry ID
 */
function delete_form_custom_fields(string $content_type, string $id)
{
    if (!addon_installed('catalogues')) {
        return;
    }

    require_code('catalogues2');

    $existing = get_bound_content_entry($content_type, $id);
    if ($existing !== null) {
        actual_delete_catalogue_entry($existing);

        $GLOBALS['SITE_DB']->query_delete('catalogue_entry_linkage', [
            'catalogue_entry_id' => $existing,
        ]);
    }
}

/**
 * Get a list of all field types to choose from.
 *
 * @param  ID_TEXT $type Field type to select
 * @param  boolean $limit_to_storage_set Whether to only show options in the same storage set as $type
 * @return Tempcode List of field types
 */
function create_selection_list_field_type(string $type = '', bool $limit_to_storage_set = false) : object
{
    static $cache = [];
    $cache_sig = serialize([$type, $limit_to_storage_set]);
    if (isset($cache[$cache_sig])) {
        return $cache[$cache_sig];
    }

    $do_caching = has_caching_for('block', '_field_type_selection');

    $ret = null;
    if ($do_caching) {
        $cache_identifier = $cache_sig;
        $ret = get_cache_entry('_field_type_selection', $cache_identifier, CACHE_AGAINST_NOTHING_SPECIAL, 10000);

        if ($ret !== null) {
            $cache[$cache_sig] = $ret;
            return $ret;
        }
    }

    require_lang('fields');

    $all_types = find_all_hooks('systems', 'fields');
    if ($limit_to_storage_set) { // Already set, so we need to do a search to see what we can limit our types to (things with the same backend DB storage)
        $ob = get_fields_hook($type);
        $types = [];
        list(, , $db_type) = $ob->get_field_value_row_bits(null);
        foreach ($all_types as $this_type => $hook_type) {
            $ob = get_fields_hook($this_type);
            list(, , $this_db_type) = $ob->get_field_value_row_bits(null);

            if ($this_db_type == $db_type) {
                $types[$this_type] = $hook_type;
            }
        }
    } else {
        $types = $all_types;
    }
    $orderings = [
        do_lang_tempcode('FIELD_TYPES__TEXT'), 'short_trans', 'short_trans_multi', 'short_text', 'short_text_multi', 'long_trans', 'long_text', 'posting_field', 'codename', 'password', 'email',
        do_lang_tempcode('FIELD_TYPES__NUMBERS'), 'integer', 'float',
        do_lang_tempcode('FIELD_TYPES__CHOICES'), 'list', 'list_multi', 'state', 'country', 'region', 'tick',
        do_lang_tempcode('FIELD_TYPES__UPLOADSANDURLS'), 'upload', 'upload_multi', 'picture', 'picture_multi', 'video', 'video_multi', 'url', 'url_multi', 'page_link', 'theme_image',
        do_lang_tempcode('FIELD_TYPES__MAGIC'), 'guid',
        do_lang_tempcode('FIELD_TYPES__REFERENCES'), 'isbn', 'reference', 'reference_multi', 'content_link', 'content_link_multi', 'member', 'member_multi', 'author',
        //do_lang_tempcode('FIELD_TYPES__OTHER'), 'color', 'date_time', 'date', 'time', 'tel',       Will go under OTHER automatically
    ];
    $_types = [];
    $done_one_in_section = true;
    foreach ($orderings as $o) {
        if (is_object($o)) {
            if (!$done_one_in_section) {
                array_pop($_types);
            }
            $_types[] = $o;
            $done_one_in_section = false;
        } else {
            if (isset($types[$o])) {
                $_types[] = $o;
                unset($types[$o]);
                $done_one_in_section = true;
            }
        }
    }
    if (!$done_one_in_section) {
        array_pop($_types);
    }
    if (!empty($types)) {
        $types = array_merge($_types, [do_lang_tempcode('FIELD_TYPES__OTHER')], array_keys($types));
    } else {
        $types = $_types;
    }
    $_type_list = ''; // XHTMLXHTML
    $type_list = new Tempcode();
    $last_type = do_lang_tempcode('OTHER');
    foreach ($types as $_type) {
        if (is_object($_type)) {
            if ($_type_list !== '') {
                $type_list->attach(form_input_list_group($last_type, make_string_tempcode($_type_list)));
            }
            $_type_list = '';
            $last_type = $_type;
        } else {
            $ob = get_fields_hook($_type);
            if (method_exists($ob, 'get_field_types')) {
                $sub_types = $ob->get_field_types();
            } else {
                $sub_types = [$_type => do_lang_tempcode('FIELD_TYPE_' . $_type)];
            }

            foreach ($sub_types as $__type => $_title) {
                $_type_list .= '<option value="' . escape_html($__type) . '"' . (($__type == $type) ? ' selected="selected"' : '') . '>' . $_title->evaluate() . '</option>';
            }
        }
    }
    if ($_type_list !== '') {
        $type_list->attach(form_input_list_group($last_type, make_string_tempcode($_type_list)));
    }

    $ret = make_string_tempcode($type_list->evaluate());

    $cache[$cache_sig] = $ret;

    if ($do_caching) {
        require_code('caches2');

        $ret = apply_quick_caching($ret);

        set_cache_entry('_field_type_selection', 60 * 24, $cache_identifier, $ret);
    }

    return $ret;
}

/**
 * Used by list fields to derive list data based on field options.
 *
 * @package core_fields
 */
abstract class ListFieldHook
{
    /**
     * Get field list.
     *
     * @param  array $field The field details
     * @param  ?boolean $dynamic_choices Whether to put custom choices from previous data back into the main list (null: decide based on field options)
     * @return array List
     */
    protected function get_input_list_map(array $field, ?bool $dynamic_choices = null) : array
    {
        $default = option_value_from_field_array($field, 'default', $field['cf_default']);

        switch ($default) {
            case 'CURRENCY':
                if (addon_installed('ecommerce')) {
                    require_code('currency');
                    $currencies = array_keys(get_currency_map());
                    $list = array_combine($currencies, $currencies);
                } else {
                    $list = [];
                }
                break;

            case 'REGION':
                require_code('locations');
                $continents_and_countries = find_continents_and_countries();
                $list = [];
                foreach ($continents_and_countries as $continent => $countries) {
                    foreach ($countries as $country_code => $country_name) {
                        $list[$country_code] = $continent . ' > ' . $country_name;
                    }
                }
                break;

            case 'COUNTRY':
                require_code('locations');
                $list = find_countries();
                break;

            case 'USA_STATE':
                require_code('locations');
                global $USA_STATE_LIST;
                $list = $USA_STATE_LIST;
                break;

            default:
                if ((addon_installed('nested_cpf_spreadsheet_lists')) && (cms_strtolower_ascii(substr($default, -4)) == '.spreadsheet')) {
                    $spreadsheet_heading = option_value_from_field_array($field, 'spreadsheet_heading', '');

                    require_code('nested_spreadsheet');
                    $spreadsheet_structure = get_nested_spreadsheet_structure();

                    $list = [];
                    foreach ($spreadsheet_structure['spreadsheet_files'][$default]['data'] as $row) {
                        if ($spreadsheet_heading == '') {
                            $l = array_shift($row);
                            $list[$l] = $l;
                        } else {
                            $l = $row[$spreadsheet_heading];
                            $list[$l] = $l;
                        }
                    }
                } else {
                    if ($default == '') {
                        $list = [];
                    } else {
                        if (substr_count($default, '|') + 1 == substr_count($default, '=')) {
                            foreach (explode('|', $default) as $l) {
                                list($l, $written) = explode('=', $l, 2);
                                $list[$l] = $written;
                            }
                        } else {
                            foreach (explode('|', $default) as $l) {
                                $list[preg_replace('#=.*$#', '', $l)] = preg_replace('#^.*=#', '', $l);
                            }
                        }
                    }
                }
                break;
        }

        $custom_values = option_value_from_field_array($field, 'custom_values', 'off');

        if ($custom_values != 'off') { // Only makes sense to allow dynamic choices if custom values are enterable
            if ($dynamic_choices === null) {
                $dynamic_choices = (option_value_from_field_array($field, 'dynamic_choices', 'off') == 'on');
            }
            if (isset($field['c_name'])) {
                $existing_data = $GLOBALS['SITE_DB']->query_select('catalogue_efv_long', ['DISTINCT cv_value AS d'], ['cf_id' => $field['id']]);
            } else {
                $existing_data = $GLOBALS['FORUM_DB']->query_select('f_member_custom_fields', ['DISTINCT field_' . strval($field['id']) . ' AS d']);
            }
            foreach ($existing_data as $d) {
                if ($d['d'] != '') {
                    $parts = explode("\n", $d['d']);
                    $list += array_combine($parts, $parts);
                }
            }
        }

        return $list;
    }
}
