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

/**
 * Standard code module initialisation function.
 *
 * @ignore
 */
function init__cns_members_action2()
{
    global $ANY_FIELD_ENCRYPTED;
    $ANY_FIELD_ENCRYPTED = null;
}

/**
 * Get extended field mapping data for spreadsheet import/export.
 *
 * @return array Triple: headings, CPFs, subscription type data
 */
function member_get_spreadsheet_headings_extended() : array
{
    // Read CPFs
    $cpfs = list_to_map('id', $GLOBALS['FORUM_DB']->query_select('f_custom_fields', ['id', 'cf_type', 'cf_name', 'cf_order'], [], 'ORDER BY cf_order,' . $GLOBALS['FORUM_DB']->translate_field_ref('cf_name')));

    // Headings
    $headings = member_get_spreadsheet_headings();
    foreach ($cpfs as $i => $c) { // CPFs take precedence over normal fields of the same name
        $cpfs[$i]['_cf_name'] = get_translated_text($c['cf_name'], $GLOBALS['FORUM_DB']);
        $headings[$cpfs[$i]['_cf_name']] = strval($i); // We specially recognise numeric names as a map back to a CPF ID
    }

    // Subscription types
    $subscription_types = [];
    if (addon_installed('ecommerce')) {
        require_lang('ecommerce');

        $usergroup_subscription_rows = $GLOBALS['FORUM_DB']->query_select('f_usergroup_subs', ['id', 's_title']);
        foreach ($usergroup_subscription_rows as $usergroup_subscription_row) {
            $item_name = get_translated_text($usergroup_subscription_row['s_title'], $GLOBALS['FORUM_DB']);
            $heading_lang_strings = [
                'SUBSCRIPTION_START_TIME',
                'SUBSCRIPTION_TERM_START_TIME',
                'SUBSCRIPTION_TERM_END_TIME',
                'SUBSCRIPTION_EXPIRY_TIME',
                'PAYMENT_GATEWAY',
                'STATUS',
            ];
            foreach ($heading_lang_strings as $heading_lang_string) {
                $headings[$item_name . ' (' . do_lang($heading_lang_string) . ')'] = ':' . str_replace('/', '\\', $item_name . ' (' . do_lang($heading_lang_string) . ')'); // Forward slashes are assumed as delimiters
            }
            $subscription_types['USERGROUP' . strval($usergroup_subscription_row['id'])] = $item_name;
        }
    }

    return [$headings, $cpfs, $subscription_types];
}

/**
 * Get field mapping data for spreadsheet import/export.
 *
 * @return array A map of heading information (human name to field name/encoding details)
 */
function member_get_spreadsheet_headings() : array
{
    $headings = [
        'ID' => 'id',
        'Username' => 'm_username',
        'E-mail address' => 'm_email_address',
    ];
    if (has_privilege(get_member(), 'assume_any_member')) {
        $headings += [
            'Password' => 'm_pass_hash_salted/m_pass_salt/m_password_compat_scheme',
        ];
    }
    if (addon_installed('cns_member_avatars')) {
        $headings += [
            'Avatar' => '#m_avatar_url',
        ];
    }
    if (addon_installed('cns_member_photos')) {
        $headings += [
            'Photo' => '#m_photo_url',
        ];
    }
    $headings += [
        'Signature' => '*m_signature',
        'Validated' => '!m_validated',
        'Join time' => '&m_join_time',
        'Last visit' => '&m_last_visit_time',
        'Number of posts' => 'm_cache_num_posts',
        'Usergroup' => '@m_primary_group',
        'Banned' => 'm_is_perm_banned',
        'Date of birth' => 'm_dob_year/m_dob_month/m_dob_day',
        'Reveal age' => '!m_reveal_age',
        'Language' => 'm_language',
        'Accept member e-mails' => '!m_allow_emails',
        'Opt-in' => '!m_allow_emails_from_staff',
    ];
    return $headings;
}

/**
 * If we are using human names for usernames, a conflict is likely. Store a suffixed variety.
 * TODO: Maybe later the software will strip these suffixes out in some contexts.
 *
 * @param  SHORT_TEXT $username The desired human name for the member profile
 * @return SHORT_TEXT A unique username
 */
function get_username_from_human_name(string $username) : string
{
    $username = preg_replace('# \(\d+\)$#', '', $username);
    $_username = $username;
    $i = 1;
    do {
        $test = $GLOBALS['FORUM_DB']->query_select_value_if_there('f_members', 'id', ['m_username' => $_username]);
        if ($test !== null) {
            $i++;
            $_username = $username . ' (' . strval($i) . ')';
        }
    } while ($test !== null);
    $username = $_username;
    return $username;
}

/**
 * Get a form for finishing off a member profile (such as for LDAP or httpauth, where a partial profile is automatically made, but needs completion).
 *
 * @param  ID_TEXT $type The type of member profile we are finishing off
 * @param  SHORT_TEXT $username The username for the member profile
 * @param  EMAIL $email_address Auto-detected e-mail address (blank: none)
 * @param  ?integer $dob_day Auto-detected DOB day (null: unknown)
 * @param  ?integer $dob_month Auto-detected DOB month (null: unknown)
 * @param  ?integer $dob_year Auto-detected DOB year (null: unknown)
 * @param  ?ID_TEXT $timezone Auto-detected Timezone (null: unknown)
 * @param  ?ID_TEXT $language Auto-detected Language (null: unknown)
 * @return Tempcode The form
 */
function cns_member_external_linker_ask(string $type, string $username, string $email_address = '', ?int $dob_day = null, ?int $dob_month = null, ?int $dob_year = null, ?string $timezone = null, ?string $language = null) : object
{
    require_lang('cns');

    // If somehow, we're not fully started up, or in a messy state
    require_code('urls');

    cms_ob_end_clean(); // Emergency output, potentially, so kill off any active buffer

    $title = get_screen_title('FINISH_PROFILE');

    if ($username != '') {
        $username = get_username_from_human_name($username);
    }

    list($fields, $hidden) = cns_get_member_fields(true, $type, null, $username, $email_address, null, null, $dob_day, $dob_month, $dob_year, null, $timezone, $language);
    $hidden->attach(build_keep_post_fields());
    $hidden->attach(form_input_hidden('finishing_profile', '1'));

    $text = do_lang_tempcode('ENTER_PROFILE_DETAILS_FINISH');

    $submit_name = do_lang_tempcode('PROCEED');
    $url = get_self_url();

    return do_template('FORM_SCREEN', [
        '_GUID' => 'f3fa74f4842f3660f0831f8d708d256d',
        'HIDDEN' => $hidden,
        'TITLE' => $title,
        'FIELDS' => $fields,
        'TEXT' => $text,
        'SUBMIT_ICON' => 'menu/site_meta/user_actions/join',
        'SUBMIT_NAME' => $submit_name,
        'URL' => $url,
    ]);
}

/**
 * Finishing off of a member profile (such as for LDAP or httpauth, where a partial profile is automatically made, but needs completion).
 *
 * @param  ID_TEXT $type The type of member profile we are finishing off
 * @param  SHORT_TEXT $username The username for the member profile
 * @param  SHORT_TEXT $password The password for the member profile
 * @param  boolean $email_check Whether to check for duplicated e-mail addresses
 * @param  EMAIL $email_address Auto-detected e-mail address (blank: none)
 * @param  ?integer $dob_day Auto-detected DOB day (null: unknown)
 * @param  ?integer $dob_month Auto-detected DOB month (null: unknown)
 * @param  ?integer $dob_year Auto-detected DOB year (null: unknown)
 * @param  ?ID_TEXT $timezone Auto-detected Timezone (null: unknown)
 * @param  ?ID_TEXT $language Auto-detected Language (null: unknown)
 * @param  ?URLPATH $avatar_url The URL to the member's avatar (blank: none) (null: choose one automatically)
 * @param  URLPATH $photo_url The URL to the member's photo (blank: none)
 * @return MEMBER The member ID for the finished off profile
 */
function cns_member_external_linker(string $type, string $username, string $password, bool $email_check = true, string $email_address = '', ?int $dob_day = null, ?int $dob_month = null, ?int $dob_year = null, ?string $timezone = null, ?string $language = null, ?string $avatar_url = null, string $photo_url = '') : int
{
    // Read in data...

    require_code('temporal');
    require_code('temporal2');
    require_code('cns_groups');

    $email_address = post_param_string('email', $email_address, INPUT_FILTER_POST_IDENTIFIER);

    $groups = cns_get_all_default_groups(true); // $groups will contain the built in default primary group too (it is not $secondary_groups)
    $primary_group = post_param_integer('primary_group', null);
    if (($primary_group !== null) && (!in_array($primary_group, $groups)/*= not built in default, which is automatically ok to join without extra security*/)) {
        // Check security
        $test = $GLOBALS['FORUM_DB']->query_select_value('f_groups', 'g_is_presented_at_install', ['id' => $primary_group]);
        if ($test == 1) {
            $groups = cns_get_all_default_groups(false); // Get it so it does not include the built in default primary group
            $groups[] = $primary_group; // And add in the *chosen* primary group
        } else {
            $primary_group = null;
        }
    } else {
        $primary_group = null;
    }
    if ($primary_group === null) { // Security error, or built in default (which will already be in $groups)
        $primary_group = get_first_default_group();
    }

    list($dob_year, $dob_month, $dob_day) = post_param_date_components('birthday', $dob_year, $dob_month, $dob_day);

    $custom_fields = cns_get_all_custom_fields_match(
        cns_get_all_default_groups(true), // groups
        null, // public view
        null, // owner view
        null, // owner set
        null, // required
        null, // show in posts
        null, // show in post previews
        null, // special start
        true // show on join form
    );
    $actual_custom_fields = cns_read_in_custom_fields($custom_fields);
    foreach ($actual_custom_fields as $key => $val) {
        if ($val == STRING_MAGIC_NULL) {
            $actual_custom_fields[$key] = '';
        }
    }

    $timezone = post_param_string('timezone', $timezone);
    $language = post_param_string('language', $language);

    $allow_emails = post_param_integer('allow_emails', 0); // For default privacy, default off
    $allow_emails_from_staff = post_param_integer('allow_emails_from_staff', 0); // For default privacy, default off

    $reveal_age = post_param_integer('reveal_age', 0); // For default privacy, default off

    // Check that the given address isn't already used (if one_per_email_address on)
    if ((get_option('one_per_email_address') != '0') && ($email_address != '') && ($email_check)) {
        $test = $GLOBALS['FORUM_DB']->query_select_value_if_there('f_members', 'm_username', ['m_email_address' => $email_address]);
        if ($test !== null) {
            global $MEMBER_CACHED;
            $MEMBER_CACHED = db_get_first_id();
            $reset_url = build_url(['page' => 'lost_password', 'email' => $email_address], get_module_zone('lost_password'));
            warn_exit(do_lang_tempcode('EMAIL_ADDRESS_IN_USE', escape_html(get_site_name()), escape_html($reset_url->evaluate())));
        }
    }

    $require_new_member_validation = (get_option('require_new_member_validation') == '1');
    $validated = $require_new_member_validation ? 0 : 1;
    if ($require_new_member_validation) {
        require_code('site');
        attach_message(do_lang_tempcode('AWAITING_MEMBER_VALIDATION'), 'notice');
    }

    // Add member
    require_code('cns_members_action');
    $ret = cns_make_member(
        $username, // username
        $password, // password
        $email_address, // email_address
        null, // primary_group
        $groups, // secondary_groups
        $dob_day, // dob_day
        $dob_month, // dob_month
        $dob_year, // dob_year
        $actual_custom_fields, // custom_fields
        $timezone, // timezone
        $language, // language
        '', // theme
        '', // title
        $photo_url, // photo_url
        $avatar_url, // avatar_url
        '', // signature
        1, // preview_posts
        $reveal_age, // reveal_age
        1, // views_signatures
        null, // auto_monitor_contrib_content
        null, // smart_topic_notification
        null, // mailing_list_style
        1, // auto_mark_read
        null, // sound_enabled
        $allow_emails, // allow_emails
        $allow_emails_from_staff, // allow_emails_from_staff
        0, // highlighted_name
        '*', // pt_allow
        '', // pt_rules_text
        $validated, // validated
        '', // validated_email_confirm_code
        null, // probation_expiration_time
        '0', // is_perm_banned
        false, // check_correctness
        null, // ip_address
        $type // password_compatibility_scheme
    );
    return $ret;
}

/**
 * Read in the Custom Profile Field POST data.
 *
 * @param  array $custom_fields The CPF field rows that we'll be reading in the member's values for
 * @param  ?MEMBER $member_id Member involved (null: new member)
 * @return array The CPF data
 */
function cns_read_in_custom_fields(array $custom_fields, ?int $member_id = null) : array
{
    require_code('fields');
    require_code('cns_members_action');

    $actual_custom_fields = [];
    foreach ($custom_fields as $custom_field) {
        $ob = get_fields_hook($custom_field['cf_type']);

        $old_value = ($member_id === null) ? null : $GLOBALS['FORUM_DB']->query_select_value_if_there('f_member_custom_fields', 'field_' . strval($custom_field['id']), ['mf_member_id' => $member_id]);

        // Field not required if not yet filled in but member already registered, if PRIVILEGE ON for that. Prevents annoyance for new required CPFs added later.
        if (!member_field_is_required($member_id, 'required_cpfs', $old_value)) {
            $custom_field['cf_required'] = 0;
        }

        $value = $ob->inputted_to_field_value($member_id !== null, $custom_field, 'uploads/cns_cpf_upload', ($old_value === null) ? null : ['cv_value' => $old_value]);

        // Required field validation (a standard for all field hooks except tick)
        if (($custom_field['cf_type'] != 'tick') && ($custom_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('f_custom_fields', $custom_field, 'cf_name')));
        }

        if ((fractional_edit()) && ($value != STRING_MAGIC_NULL)) {
            $rendered = $ob->render_field_value($custom_field, $value, 0, null, 'f_member_custom_fields', $member_id, 'ce_id', 'cf_id', 'field_' . strval($custom_field['id']), $member_id);
            $_POST['field_' . strval($custom_field['id']) . '__altered_rendered_output'] = is_object($rendered) ? $rendered->evaluate() : $rendered;
        }
        $actual_custom_fields[$custom_field['id']] = $value;
    }
    return $actual_custom_fields;
}

/**
 * Get form fields for adding/editing/finishing a member account.
 *
 * @param  boolean $mini_mode Whether we are only handling the essential details of a profile
 * @param  ID_TEXT $special_type The special type of profile this is (blank: not a special type)
 * @param  ?MEMBER $member_id The ID of the member we are handling (null: new member)
 * @param  SHORT_TEXT $username The username
 * @param  SHORT_TEXT $email_address The e-mail address
 * @param  ?GROUP $primary_group The member's primary usergroup (null: not known)
 * @param  ?array $groups A list of usergroups (null: default/current usergroups)
 * @param  ?integer $dob_day Day of date of birth (null: not known)
 * @param  ?integer $dob_month Month of date of birth (null: not known)
 * @param  ?integer $dob_year Year of date of birth (null: not known)
 * @param  ?array $custom_fields A map of custom fields values (field-id=>value) (null: not known)
 * @param  ?ID_TEXT $timezone The member timezone (null: site default)
 * @param  ?LANGUAGE_NAME $language The member's language (null: auto detect)
 * @param  ?ID_TEXT $theme The member's default theme (null: not known)
 * @param  BINARY $preview_posts Whether posts are previewed before they are made
 * @param  BINARY $reveal_age Whether the member's age may be shown
 * @param  BINARY $views_signatures Whether the member sees signatures in posts
 * @param  ?BINARY $auto_monitor_contrib_content Whether the member automatically is enabled for notifications for content they contribute to (null: get default from config)
 * @param  ?BINARY $smart_topic_notification Whether to do smart topic notification [i.e. avoid sending so many notifications] (null: global configured default)
 * @param  ?BINARY $mailing_list_style Whether to send mailing-list style notifications (null: global configured default)
 * @param  BINARY $auto_mark_read Mark topics as read automatically
 * @param  ?BINARY $sound_enabled Whether sound is enabled (null: global configured default)
 * @param  BINARY $allow_emails Whether the member allows e-mails via the site
 * @param  BINARY $allow_emails_from_staff Whether the member allows e-mails from staff via the site
 * @param  BINARY $highlighted_name Whether the member username will be highlighted
 * @param  SHORT_TEXT $pt_allow Usergroups that may PT the member
 * @param  LONG_TEXT $pt_rules_text Rules that other members must agree to before they may start a PT with the member
 * @param  BINARY $validated Whether the account has been validated
 * @param  ?TIME $probation_expiration_time When the member is on probation until (null: just finished probation / or effectively was never on it)
 * @param  ID_TEXT $is_perm_banned Whether the member is permanently banned
 * @param  array $adjusted_config_options A map of adjusted config options
 * @return array A tuple: The form fields, Hidden fields (both Tempcode), Whether separate sections were used
 */
function cns_get_member_fields(bool $mini_mode = true, string $special_type = '', ?int $member_id = null, string $username = '', string $email_address = '', ?int $primary_group = null, ?array $groups = null, ?int $dob_day = null, ?int $dob_month = null, ?int $dob_year = null, ?array $custom_fields = null, ?string $timezone = null, ?string $language = null, ?string $theme = null, int $preview_posts = 0, int $reveal_age = 1, int $views_signatures = 1, ?int $auto_monitor_contrib_content = null, ?int $smart_topic_notification = null, ?int $mailing_list_style = null, int $auto_mark_read = 1, ?int $sound_enabled = null, int $allow_emails = 1, int $allow_emails_from_staff = 1, int $highlighted_name = 0, string $pt_allow = '*', string $pt_rules_text = '', int $validated = 1, ?int $probation_expiration_time = null, string $is_perm_banned = '0', array $adjusted_config_options = []) : array
{
    $fields = new Tempcode();
    $hidden = new Tempcode();

    list($_fields, $_hidden, $added_section_1) = cns_get_member_fields_settings($mini_mode, $special_type, $member_id, $username, $email_address, $primary_group, $groups, $dob_day, $dob_month, $dob_year, $timezone, $language, $theme, $preview_posts, $reveal_age, $views_signatures, $auto_monitor_contrib_content, $smart_topic_notification, $mailing_list_style, $auto_mark_read, $sound_enabled, $allow_emails, $allow_emails_from_staff, $highlighted_name, $pt_allow, $pt_rules_text, $validated, $probation_expiration_time, $is_perm_banned, $adjusted_config_options);
    $fields->attach($_fields);
    $hidden->attach($_hidden);

    if (!$mini_mode) {
        $fields->attach(do_template('FORM_SCREEN_FIELD_SPACER', [
            '_GUID' => '14205f6bf83c469a1404d24967d7b6f6',
            'TITLE' => do_lang_tempcode('PROFILE'),
            'SECTION_HIDDEN' => (get_page_name() == 'admin_cns_members'),
        ]));
        $added_section_1 = true;
    }

    list($_fields, $_hidden, $added_section_2) = cns_get_member_fields_profile($mini_mode, $member_id, $groups, $custom_fields, $adjusted_config_options);
    $fields->attach($_fields);
    $hidden->attach($_hidden);

    return [$fields, $hidden, $added_section_1 || $added_section_2];
}

/**
 * Get form fields for adding/editing/finishing a member account: settings only.
 *
 * @param  boolean $mini_mode Whether we are only handling the essential details of a profile
 * @param  ID_TEXT $special_type The special type of profile this is (blank: not a special type)
 * @param  ?MEMBER $member_id The ID of the member we are handling (null: new member)
 * @param  SHORT_TEXT $username The username
 * @param  SHORT_TEXT $email_address The e-mail address
 * @param  ?GROUP $primary_group The member's primary usergroup (null: not known)
 * @param  ?array $groups A list of usergroups (null: default usergroups)
 * @param  ?integer $dob_day Day of date of birth (null: not known)
 * @param  ?integer $dob_month Month of date of birth (null: not known)
 * @param  ?integer $dob_year Year of date of birth (null: not known)
 * @param  ?ID_TEXT $timezone The member timezone (null: site default)
 * @param  ?LANGUAGE_NAME $language The member's language (null: auto detect)
 * @param  ?ID_TEXT $theme The member's default theme (null: not known)
 * @param  ?BINARY $preview_posts Whether posts are previewed before they are made (null: calculate statistically)
 * @param  BINARY $reveal_age Whether the member's age may be shown
 * @param  BINARY $views_signatures Whether the member sees signatures in posts
 * @param  ?BINARY $auto_monitor_contrib_content Whether the member automatically is enabled for notifications for content they contribute to (null: get default from config)
 * @param  ?BINARY $smart_topic_notification Whether to do smart topic notification [i.e. avoid sending so many notifications] (null: global configured default)
 * @param  ?BINARY $mailing_list_style Whether to send mailing-list style notifications (null: global configured default)
 * @param  BINARY $auto_mark_read Mark topics as read automatically
 * @param  ?BINARY $sound_enabled Whether sound is enabled (null: global configured default)
 * @param  BINARY $allow_emails Whether the member allows e-mails via the site
 * @param  BINARY $allow_emails_from_staff Whether the member allows e-mails from staff via the site
 * @param  BINARY $highlighted_name Whether the member username will be highlighted
 * @param  SHORT_TEXT $pt_allow Usergroups that may PT the member
 * @param  LONG_TEXT $pt_rules_text Rules that other members must agree to before they may start a PT with the member
 * @param  BINARY $validated Whether the account has been validated
 * @param  ?TIME $probation_expiration_time When the member is on probation until (null: just finished probation / or effectively was never on it)
 * @param  ID_TEXT $is_perm_banned Whether the member is permanently banned
 * @param  array $adjusted_config_options A map of adjusted config options
 * @return array A pair: The form fields, Hidden fields (both Tempcode), Whether separate sections were used
 */
function cns_get_member_fields_settings(bool $mini_mode = true, string $special_type = '', ?int $member_id = null, string $username = '', string $email_address = '', ?int $primary_group = null, ?array $groups = null, ?int $dob_day = null, ?int $dob_month = null, ?int $dob_year = null, ?string $timezone = null, ?string $language = null, ?string $theme = null, ?int $preview_posts = null, int $reveal_age = 1, int $views_signatures = 1, ?int $auto_monitor_contrib_content = null, ?int $smart_topic_notification = null, ?int $mailing_list_style = null, int $auto_mark_read = 1, ?int $sound_enabled = null, int $allow_emails = 1, int $allow_emails_from_staff = 1, int $highlighted_name = 0, string $pt_allow = '*', string $pt_rules_text = '', int $validated = 1, ?int $probation_expiration_time = null, string $is_perm_banned = '0', array $adjusted_config_options = []) : array
{
    require_code('form_templates');
    require_code('cns_members_action');
    require_code('cns_field_editability');
    require_code('form_templates');
    require_code('encryption');

    $added_section = false;

    if (($special_type == '') && ($member_id !== null)) {
        $special_type = get_member_special_type($member_id);
    }

    $default_primary_group = get_first_default_group();
    if ($groups === null) {
        $groups = cns_get_all_default_groups(true);
    }
    $preview_posts = take_param_int_modeavg($preview_posts, 'm_preview_posts', 'f_members', 0);

    // Not needed as it is managed on the notifications tab, and may cause errors if cns_forum is not installed
    /*
    if ($auto_monitor_contrib_content === null) {
        $auto_monitor_contrib_content = (get_option_with_overrides('allow_auto_notifications', $adjusted_config_options) == '0') ? 0 : 1;
    }
    if ($smart_topic_notification === null) {
        $smart_topic_notification = (get_option_with_overrides('smart_topic_notification_default', $adjusted_config_options) == '1') ? 1 : 0;
    }
    if ($mailing_list_style === null) {
        $mailing_list_style = (get_option_with_overrides('mailing_list_style_default', $adjusted_config_options) == '1') ? 1 : 0;
    }
    */

    if ($sound_enabled === null) {
        $sound_enabled = ((addon_installed('cns_forum')) && (get_option_with_overrides('sound_enabled_default', $adjusted_config_options) == '1')) ? 1 : 0;
    }

    $hidden = new Tempcode();

    if ($member_id === $GLOBALS['CNS_DRIVER']->get_guest_id()) {
        fatal_exit(do_lang_tempcode('INTERNAL_ERROR'));
    }

    $fields = new Tempcode();

    // Username
    if (cns_field_editable('username', $special_type)) {
        if (($member_id === null) || (has_actual_page_access(get_member(), 'admin_cns_members')) || (has_privilege($member_id, 'rename_self'))) {
            $prohibit_username_whitespace = get_option_with_overrides('prohibit_username_whitespace', $adjusted_config_options);
            if ($prohibit_username_whitespace == '1') {
                $pattern = '[^\s]*';
                $pattern_error = do_lang('USERNAME_PASSWORD_WHITESPACE');
            } else {
                $pattern = null;
                $pattern_error = null;
            }
            $fields->attach(form_input_line(do_lang_tempcode('USERNAME'), do_lang_tempcode('DESCRIPTION_USERNAME'), ($member_id === null) ? 'username' : 'edit_username', $username, true, null, null, 'text', null, $pattern, $pattern_error));
        }
    }

    // Work out what options we need to present
    $doing_timezones = (get_option_with_overrides('enable_timezones', $adjusted_config_options) == (($member_id === null) ? '2' : '1'));
    $_langs = find_all_langs();
    if (((multi_lang()) || ((isset($adjusted_config_options['enable_language_selection'])) && ($adjusted_config_options['enable_language_selection'])))) {
        $doing_langs = (get_option_with_overrides('enable_language_selection', $adjusted_config_options) == (($member_id === null) ? '2' : '1'));
    } else {
        $doing_langs = false;
    }
    $doing_email_option = (get_option_with_overrides('member_email_receipt_configurability', $adjusted_config_options) == (($member_id === null) ? '2' : '1')) && (addon_installed('cns_contact_member'));
    $doing_email_from_staff_option = (get_option_with_overrides('staff_email_receipt_configurability', $adjusted_config_options) == (($member_id === null) ? '2' : '1'));
    $unspecced_theme_zone_exists = $GLOBALS['SITE_DB']->query_value_if_there('SELECT COUNT(*) FROM ' . get_table_prefix() . 'zones WHERE ' . db_string_equal_to('zone_theme', '') . ' OR ' . db_string_equal_to('zone_theme', '-1'));
    $doing_theme_option = ($unspecced_theme_zone_exists != 0) && (!$mini_mode);
    $doing_local_forum_options = (addon_installed('cns_forum')) && (!$mini_mode);

    // E-mail address
    if (cns_field_editable('email', $special_type)) {
        if ($email_address == '') {
            $email_address = get_param_string('email', '', INPUT_FILTER_GET_IDENTIFIER);
        }
        $email_description = new Tempcode();
        $valid_email_domains = get_option_with_overrides('valid_email_domains', $adjusted_config_options);
        if (($valid_email_domains != '') && ($mini_mode)) { // domain restriction only applies on public join form ($mini_mode)
            $email_description = do_lang_tempcode('MUST_BE_EMAIL_DOMAIN', '<kbd>*.' . preg_replace('#\s*,\s*#', '</kbd>, <kbd>*.', escape_html($valid_email_domains)) . '</kbd>', escape_html($valid_email_domains));
        } else {
            if (get_option_with_overrides('email_confirm_join', $adjusted_config_options) == '1') {
                $email_description = do_lang_tempcode('MUST_BE_REAL_ADDRESS');
            }
        }

        $email_address_required = member_field_is_required($member_id, 'email_address');

        $fields->attach(form_input_email(do_lang_tempcode('EMAIL_ADDRESS'), $email_description, 'email', $email_address, $email_address_required));
        if (($member_id === null) && ($email_address == '') && (get_option_with_overrides('email_confirm_join', $adjusted_config_options) == '1')) {
            $fields->attach(form_input_email(do_lang_tempcode('CONFIRM_EMAIL_ADDRESS'), '', 'email_address_confirm', '', $email_address_required));
        }
    }

    // E-mail privacy
    if ($doing_email_option) {
        $field_title = do_lang_tempcode('ALLOW_EMAILS');
        if (cns_field_editable('email', $special_type)) {
            $field_title = do_lang_tempcode('RELATED_FIELD', $field_title);
        }
        $fields->attach(form_input_tick($field_title, do_lang_tempcode('DESCRIPTION_ALLOW_EMAILS'), 'allow_emails', $allow_emails == 1));
    }
    if ($doing_email_from_staff_option) {
        $field_title = do_lang_tempcode('ALLOW_EMAILS_FROM_STAFF');
        if (cns_field_editable('email', $special_type)) {
            $field_title = do_lang_tempcode('RELATED_FIELD', $field_title);
        }
        $fields->attach(form_input_tick($field_title, do_lang_tempcode('DESCRIPTION_ALLOW_EMAILS_FROM_STAFF'), 'allow_emails_from_staff', $allow_emails_from_staff == 1));
    }

    // DOB
    if (cns_field_editable('dob', $special_type)) {
        $can_edit_birthday = cns_can_edit_birthday($member_id);
        $default_time = ($dob_month === null) ? null : usertime_to_utctime(cms_mktime(0, 0, 0, $dob_month, $dob_day, $dob_year));
        if (get_option_with_overrides('dobs', $adjusted_config_options) >= (($member_id === null) ? '2' : '1')) {
            $dob_required = member_field_is_required($member_id, 'dob');
            $fields->attach(form_input_date(do_lang_tempcode($dob_required ? 'DATE_OF_BIRTH' : 'ENTER_YOUR_BIRTHDAY'), $can_edit_birthday ? '' : do_lang_tempcode('DATE_OF_BIRTH_NO_SELF_EDIT'), 'birthday', $dob_required, false, false, $default_time, -130, null, null, true, null, true, null, (($member_id !== null) && (!$can_edit_birthday))));
            if (addon_installed('cns_forum')) {
                $fields->attach(form_input_tick(do_lang_tempcode('RELATED_FIELD', do_lang_tempcode('REVEAL_AGE')), do_lang_tempcode('DESCRIPTION_REVEAL_AGE'), 'reveal_age', $reveal_age == 1));
            }
        }
    }

    /*
    if (!$mini_mode) {
        if (($doing_timezones) || ($doing_langs) || ($doing_email_option) || ($doing_wide_option) || ($doing_theme_option) || ($doing_local_forum_options)) {
            $fields->attach(do_template('FORM_SCREEN_FIELD_SPACER', ['_GUID' => '3cd79bbea084ec1fe148edddad7d52b4', 'FORCE_OPEN' => ($member_id === null] ? true : null, 'TITLE' => do_lang_tempcode('SETTINGS'))));
            $added_section = true;
        }
    }
    */

    // Password (intentionally put down here as the username, e-mail address, and DOB may influence the password strength)
    if (cns_field_editable('password', $special_type)) {
        if (($member_id === null) || ($member_id == get_member()) || (has_privilege(get_member(), 'assume_any_member'))) {
            $compat_scheme = ($member_id === null) ? '' : $GLOBALS['FORUM_DRIVER']->get_member_row_field($member_id, 'm_password_compat_scheme');
            $temporary_password = ($member_id !== null) && ($member_id == get_member() && (($compat_scheme == 'temporary'/*LEGACY*/) || ($compat_scheme == 'expired'/*LEGACY*/) || ($compat_scheme == 'bcrypt_temporary') || ($compat_scheme == 'bcrypt_expired')));
            if ($temporary_password) {
                $password_field_description = do_lang_tempcode('DESCRIPTION_PASSWORD_TEMPORARY');
            } else {
                $password_field_description = do_lang_tempcode('DESCRIPTION_PASSWORD' . (($member_id !== null) ? '_EDIT' : ''));
            }
            $fields->attach(form_input_password(do_lang_tempcode(($member_id === null) ? 'PASSWORD' : 'NEW_PASSWORD'), $password_field_description, ($member_id === null) ? 'password' : 'edit_password', $mini_mode || $temporary_password, null, '', null, true));
            $fields->attach(form_input_password(do_lang_tempcode('CONFIRM_PASSWORD'), '', 'password_confirm', $mini_mode || $temporary_password));
        }
    }

    require_lang('config');

    // Timezones, if enabled
    if ($doing_timezones) {
        $fields->attach(form_input_timezone(do_lang_tempcode('TIMEZONE'), do_lang_tempcode('DESCRIPTION_TIMEZONE_MEMBER'), 'timezone', $timezone));
    }

    // Language choice, if we have multiple languages on site
    if ($doing_langs) {
        $lang_list = new Tempcode();
        $require_lang_set = (get_value('disable_required_lang_selection') !== '1');
        if (!$require_lang_set) {
            $lang_list->attach(form_input_list_entry('', empty($language), do_lang_tempcode('UNSET')));
        } else {
            if (empty($language)) {
                $language = user_lang();
            }
        }
        $lang_list->attach(create_selection_list_langs($language));
        $fields->attach(form_input_list(do_lang_tempcode('LANGUAGE'), '', 'language', $lang_list, null, false, $require_lang_set));
    }

    if (!$mini_mode) {
        // Theme, if we have any zones giving a choice
        require_code('themes2');
        $entries = create_selection_list_themes($theme, true, false, 'RELY_SITE_DEFAULT');
        require_lang('themes');
        if ($doing_theme_option) {
            $fields->attach(form_input_list(do_lang_tempcode('THEME'), do_lang_tempcode('DESCRIPTION_THEME'), 'theme', $entries));
        }

        // Various forum options
        if (addon_installed('cns_forum')) {
            if (get_option_with_overrides('forced_preview_option', $adjusted_config_options) == '1') {
                $fields->attach(form_input_tick(do_lang_tempcode('PREVIEW_POSTS'), do_lang_tempcode('DESCRIPTION_PREVIEW_POSTS'), 'preview_posts', $preview_posts == 1));
            }
            if (addon_installed('cns_signatures')) {
                if (get_option_with_overrides('enable_views_sigs_option', $adjusted_config_options) === '1') {
                    $fields->attach(form_input_tick(do_lang_tempcode('VIEWS_SIGNATURES'), do_lang_tempcode('DESCRIPTION_VIEWS_SIGNATURES'), 'views_signatures', $views_signatures == 1));
                } else {
                    $hidden->attach(form_input_hidden('views_signatures', '1'));
                }
            }

            /*
            Actually managed on the notifications tab, even though technically account settings
            $fields->attach(form_input_tick(do_lang_tempcode('AUTO_NOTIFICATION_CONTRIB_CONTENT'), do_lang_tempcode('DESCRIPTION_AUTO_NOTIFICATION_CONTRIB_CONTENT'), 'auto_monitor_contrib_content', $auto_monitor_contrib_content == 1));
            $fields->attach(form_input_tick(do_lang_tempcode('SMART_TOPIC_NOTIFICATION'), do_lang_tempcode('DESCRIPTION_SMART_TOPIC_NOTIFICATION'), 'smart_topic_notification', $smart_topic_notification == 1));
            if (addon_installed('cns_forum')) {
                require_code('cns_forums2');
                require_lang('cns_mailinglists');
                $test = cns_has_mailing_list_style();
                if ($test[0] > 0) {
                    $mlsn_description_caveat = $test[1] ? new Tempcode() : do_lang_tempcode('DESCRIPTION_MAILING_LIST_STYLE_CAVEAT');
                    $mlsn_description = do_lang_tempcode('DESCRIPTION_MAILING_LIST_STYLE', $mlsn_description_caveat);
                    $fields->attach(form_input_tick(do_lang_tempcode('MAILING_LIST_STYLE'), $mlsn_description, 'mailing_list_style', $mailing_list_style == 1));
                }
            }
            */

            if (get_option_with_overrides('is_on_automatic_mark_topic_read', $adjusted_config_options) == '0') {
                $fields->attach(form_input_tick(do_lang_tempcode('ENABLE_AUTO_MARK_READ'), do_lang_tempcode('DESCRIPTION_ENABLE_AUTO_MARK_READ'), 'auto_mark_read', $auto_mark_read == 1));
            } else {
                $hidden->attach(form_input_hidden('auto_mark_read', '1'));
            }
            $fields->attach(form_input_tick(do_lang_tempcode('SOUND_ENABLED'), do_lang_tempcode('DESCRIPTION_SOUND_ENABLED'), 'sound_enabled', $sound_enabled == 1));
            $usergroup_list = new Tempcode();
            $lgroups = $GLOBALS['CNS_DRIVER']->get_usergroup_list(true, true, false, [], null, true);
            foreach ($lgroups as $key => $val) {
                if ($key != db_get_first_id()) {
                    $usergroup_list->attach(form_input_list_entry(strval($key), ($pt_allow == '*') || (!empty(array_intersect([strval($key)], explode(',', $pt_allow)))), $val));
                }
            }

            if (get_option_with_overrides('enable_pt_restrict', $adjusted_config_options) == '1') {
                $fields->attach(do_template('FORM_SCREEN_FIELD_SPACER', ['_GUID' => '7e5deb351a7a5214fbff10049839e258', 'TITLE' => do_lang_tempcode('PRIVATE_TOPICS'), 'SECTION_HIDDEN' => ($pt_allow == '*') && ($pt_rules_text == '')]));
                $fields->attach(form_input_multi_list(do_lang_tempcode('PT_ALLOW'), addon_installed('chat') ? do_lang_tempcode('PT_ALLOW_DESCRIPTION_CHAT') : do_lang_tempcode('PT_ALLOW_DESCRIPTION'), 'pt_allow', $usergroup_list));
                $fields->attach(form_input_text_comcode(do_lang_tempcode('PT_RULES_TEXT'), do_lang_tempcode('PT_RULES_TEXT_DESCRIPTION'), 'pt_rules_text', $pt_rules_text, false));
                $added_section = true;
            }
        }

        // Prepare list of usergroups, if maybe we are gonna let (a) usergroup-change field(s)
        $group_count = $GLOBALS['FORUM_DB']->query_select_value('f_groups', 'COUNT(*)');
        $rows = $GLOBALS['FORUM_DB']->query_select('f_groups', ['id', 'g_name', 'g_hidden', 'g_open_membership', 'g_order'], ($group_count > 200) ? ['g_is_private_club' => 0] : [], 'ORDER BY g_order,' . $GLOBALS['FORUM_DB']->translate_field_ref('g_name'));
        $_groups = new Tempcode();
        $current_primary_group = null;
        foreach ($rows as $group) {
            if ($group['id'] != db_get_first_id()) {
                $selected = ($group['id'] == $primary_group) || (($primary_group === null) && ($group['id'] == $default_primary_group));
                if ($selected) {
                    $current_primary_group = $group['id'];
                }
                $_groups->attach(form_input_list_entry(strval($group['id']), $selected, get_translated_text($group['g_name'], $GLOBALS['FORUM_DB'])));
            }
        }

        // Some admin options...
        if (has_privilege(get_member(), 'member_maintenance')) {
            $fields->attach(do_template('FORM_SCREEN_FIELD_SPACER', ['_GUID' => '04422238c372edd0b11c11a05feb6267', 'TITLE' => do_lang_tempcode('MEMBER_ACCESS')]));
            $added_section = true;

            // Probation
            if (has_privilege(get_member(), 'probate_members')) {
                if (($member_id !== null) && ($member_id != get_member())) { // Can't put someone new on probation, and can't put yourself on probation
                    $fields->attach(form_input_date(do_lang_tempcode('PROBATION_EXPIRATION_TIME'), do_lang_tempcode('DESCRIPTION_PROBATION_EXPIRATION_TIME'), 'probation_expiration_time', false, ($probation_expiration_time === null) || $probation_expiration_time <= time(), true, $probation_expiration_time, 2));
                }
            }

            // Primary usergroup
            if (cns_field_editable('primary_group', $special_type)) {
                if (has_privilege(get_member(), 'assume_any_member')) {
                    if (($member_id === null) || (!$GLOBALS['FORUM_DRIVER']->is_super_admin($member_id)) || (count($GLOBALS['FORUM_DRIVER']->member_group_query($GLOBALS['FORUM_DRIVER']->get_super_admin_groups(), 2)) > 1)) {
                        $fields->attach(form_input_list(do_lang_tempcode('PRIMARY_GROUP'), do_lang_tempcode('DESCRIPTION_PRIMARY_GROUP'), 'primary_group', $_groups));
                    }
                }
            }
        }

        // Secondary usergroups
        if (cns_field_editable('secondary_groups', $special_type)) {
            $_groups2 = new Tempcode();
            $members_groups = ($member_id === null) ? [] : cns_get_members_groups($member_id, false, false, false); // We can't use $groups because it isn't tuned for a form
            foreach ($rows as $group) {
                if (($group['g_hidden'] == 1) && (!array_key_exists($group['id'], $members_groups)) && (!has_privilege(get_member(), 'see_hidden_groups'))) {
                    continue;
                }

                if (($group['id'] != db_get_first_id()) && ((array_key_exists($group['id'], $members_groups)) || (has_privilege(get_member(), 'assume_any_member')) || ($group['g_open_membership'] == 1))) {
                    $selected = array_key_exists($group['id'], $members_groups) && ($group['id'] != $current_primary_group);
                    $_groups2->attach(form_input_list_entry(strval($group['id']), $selected, get_translated_text($group['g_name'], $GLOBALS['FORUM_DB'])));
                }
            }
            $sec_url = build_url(['page' => 'groups', 'type' => 'browse'], get_module_zone('groups'));
            if (!$_groups2->is_empty()) {
                $fields->attach(form_input_multi_list(do_lang_tempcode('SECONDARY_GROUP_MEMBERSHIP'), do_lang_tempcode('DESCRIPTION_SECONDARY_GROUP', escape_html($sec_url->evaluate())), 'secondary_groups', $_groups2));
            }
        }

        // Special admin options
        if (has_privilege(get_member(), 'member_maintenance')) {
            $_validated = get_param_integer('validated', 0);
            if ($validated == 0) {
                if (($_validated == 1) && (addon_installed('validation'))) {
                    $validated = 1;
                    attach_message(do_lang_tempcode('WILL_BE_VALIDATED_WHEN_SAVING'));
                }
            } elseif (($validated == 1) && ($_validated == 1) && ($member_id !== null)) {
                $action_log = build_url(['page' => 'admin_actionlog', 'type' => 'list', 'to_type' => 'VALIDATE_MEMBER', 'param_a' => strval($member_id)]);
                attach_message(do_lang_tempcode('ALREADY_VALIDATED', escape_html($action_log->evaluate())), 'notice');
            }
            if (get_option_with_overrides('enable_highlight_name', $adjusted_config_options) == '1') {
                $fields->attach(form_input_tick(do_lang_tempcode('HIGHLIGHTED_NAME'), do_lang_tempcode(addon_installed('ecommerce') ? 'DESCRIPTION_HIGHLIGHTED_NAME_P' : 'DESCRIPTION_HIGHLIGHTED_NAME'), 'highlighted_name', $highlighted_name == 1));
            }
            if (addon_installed('validation')) {
                $fields->attach(form_input_tick(do_lang_tempcode('VALIDATED'), do_lang_tempcode('DESCRIPTION_MEMBER_VALIDATED'), 'validated', $validated == 1));
            }
            if (($member_id !== null) && ($member_id != get_member())) {// Can't ban someone new, and can't ban yourself
                require_code('input_filter');
                list(, $reasoned_bans) = load_advanced_banning();
                if (empty($reasoned_bans)) {
                    $fields->attach(form_input_tick(do_lang_tempcode('BANNED'), do_lang_tempcode('DESCRIPTION_MEMBER_BANNED'), 'is_perm_banned', $is_perm_banned != '0'));
                } else {
                    $reasoned_bans_list = new Tempcode();
                    $reasoned_bans_list->attach(form_input_list_entry('0', '0' == $is_perm_banned, do_lang_tempcode('NO')));
                    $reasoned_bans_list->attach(form_input_list_entry('1', '1' == $is_perm_banned, do_lang_tempcode('YES')));
                    foreach (array_keys($reasoned_bans) as $reasoned_ban) {
                        $reasoned_bans_list->attach(form_input_list_entry($reasoned_ban, $reasoned_ban == $is_perm_banned));
                    }
                    $fields->attach(form_input_list(do_lang_tempcode('BANNED'), do_lang_tempcode('DESCRIPTION_MEMBER_BANNED'), 'is_perm_banned', $reasoned_bans_list, null, false, false));
                }

                $fields->attach(do_template('FORM_SCREEN_FIELD_SPACER', ['_GUID' => '03452238c372edd0b11c11a05feb6267', 'TITLE' => do_lang_tempcode('ACTIONS')]));
                $fields->attach(form_input_tick(do_lang_tempcode('SENSITIVE_CHANGE_ALERT'), do_lang_tempcode('DESCRIPTION_SENSITIVE_CHANGE_ALERT'), 'sensitive_change_alert', true));
            }
        }

        if (addon_installed('content_reviews')) {
            require_code('content_reviews2');
            $content_review_fields = content_review_get_fields('member', ($member_id === null) ? null : strval($member_id));
            if (!$content_review_fields->is_empty()) {
                $fields->attach($content_review_fields);
                $added_section = true;
            }
        }
    }

    return [$fields, $hidden, $added_section];
}

/**
 * Get form fields for adding/editing/finishing a member account: profile fields only.
 *
 * @param  boolean $mini_mode Whether we are only handling the essential details of a profile
 * @param  ?MEMBER $member_id The ID of the member we are handling (null: new member)
 * @param  ?array $groups A list of usergroups (null: default/current usergroups)
 * @param  ?array $custom_fields A map of custom fields values (field-id=>value) (null: not known)
 * @param  array $adjusted_config_options A map of adjusted config options
 * @return array A tuple: The form fields, Hidden fields (both Tempcode), Whether separate sections were used
 */
function cns_get_member_fields_profile(bool $mini_mode = true, ?int $member_id = null, ?array $groups = null, ?array $custom_fields = null, array $adjusted_config_options = []) : array
{
    require_code('cns_members_action');

    $fields = new Tempcode();
    $hidden = new Tempcode();

    $added_section = false;

    if ($groups === null) {
        $groups = ($member_id === null) ? cns_get_all_default_groups(true) : $GLOBALS['CNS_DRIVER']->get_members_groups($member_id);
    }

    $_custom_fields = cns_get_all_custom_fields_match(
        $groups, // groups
        ($mini_mode || ($member_id === null) || ($member_id == get_member()) || (has_privilege(get_member(), 'view_any_profile_field'))) ? null : 1, // public view
        null, // owner view
        ($mini_mode || ($member_id === null) || ($member_id != get_member()) || (has_privilege(get_member(), 'view_any_profile_field'))) ? null : 1, // owner set
        null, // required
        null, // show in posts
        null, // show in post previews
        null, // special start
        $mini_mode ? true : null, // show on join form,
        $adjusted_config_options
    );
    $fields_to_skip = _cpfs_internal_use_only();
    $GLOBALS['NO_DEV_MODE_FULLSTOP_CHECK'] = true;
    $field_groups = [];
    require_code('fields');
    require_code('encryption');
    foreach ($_custom_fields as $custom_field) {
        // Skip internal use only fields
        if (in_array($custom_field['id'], $fields_to_skip)) {
            continue;
        }

        $ob = get_fields_hook($custom_field['cf_type']);
        list(, , $storage_type) = $ob->get_field_value_row_bits($custom_field);

        $existing_field = ($custom_fields !== null) && (array_key_exists($custom_field['trans_name'], $custom_fields));
        if ($existing_field) {
            $value = $custom_fields[$custom_field['trans_name']]['RAW'];

            if (is_data_encrypted($value)) {
                $value = remove_magic_encryption_marker($value);
            }

            if (!member_field_is_required($member_id, 'required_cpfs', $value) && $custom_field['cf_type'] != 'tick'/*FUDGE*/) {
                $custom_field['cf_required'] = 0;
            }
        } else {
            $value = $custom_field['cf_default'];

            if (!member_field_is_required($member_id, 'required_cpfs', '')) {
                $custom_field['cf_required'] = 0;
            }
        }

        $result = null;

        $_description = escape_html(get_translated_text($custom_field['cf_description'], $GLOBALS['FORUM_DB']));
        $field_cat = '';
        $matches = [];
        if (strpos($custom_field['trans_name'], ': ') !== false) {
            $field_cat = substr($custom_field['trans_name'], 0, strpos($custom_field['trans_name'], ': '));
            if ($field_cat . ': ' == $custom_field['trans_name']) {
                $custom_field['trans_name'] = $field_cat; // Just been pulled out as heading, nothing after ": "
            } else {
                $custom_field['trans_name'] = substr($custom_field['trans_name'], strpos($custom_field['trans_name'], ': ') + 2);
            }
        } elseif (preg_match('#(^\([A-Z][^\)]*\) )|( \([A-Z][^\)]*\)$)#', $custom_field['trans_name'], $matches) != 0) {
            $field_cat = trim($matches[0], '() ');
            $custom_field['trans_name'] = str_replace($matches[0], '', $custom_field['trans_name']);
        }

        $result = $ob->get_field_inputter($custom_field['trans_name'], $_description, $custom_field, $value, !$existing_field);

        if (!array_key_exists($field_cat, $field_groups)) {
            $field_groups[$field_cat] = new Tempcode();
        }

        if (is_array($result)) {
            $field_groups[$field_cat]->attach($result[0]);
            $hidden->attach($result[1]);
        } else {
            $field_groups[$field_cat]->attach($result);
        }
    }
    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);
    }
    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' => 'af91e3c040a0a18a4d9cc1143c0d2007',
                'TITLE' => $field_group_title,
                'SECTION_HIDDEN' => (get_page_name() == 'admin_cns_members'),
            ]));
            $added_section = true;
        }
        $fields->attach($extra_fields);
    }
    $GLOBALS['NO_DEV_MODE_FULLSTOP_CHECK'] = false;

    return [$fields, $hidden, $added_section];
}

/**
 * Return an array of custom fields that should never be editable on the UI.
 *
 * @return array Array of custom field IDs that should never be editable on the UI
 * @ignore
 */
function _cpfs_internal_use_only() : array
{
    require_code('cns_members');

    $ret = [];

    $fields_to_find = ['cms_points_balance', 'cms_points_lifetime']; // FUDGE

    foreach ($fields_to_find as $field) {
        $cpf_id = find_cms_cpf_field_id($field);
        if ($cpf_id !== null) {
            $ret[] = $cpf_id;
        }
    }

    return $ret;
}

/**
 * Edit a member.
 *
 * @param  AUTO_LINK $member_id The ID of the member
 * @param  ?string $username The username (null: don't change)
 * @param  ?string $password The password, plain-text if compat scheme is software-based, else hashed (null: don't change)
 * @param  ?SHORT_TEXT $email_address The e-mail address (null: don't change)
 * @param  ?GROUP $primary_group The member's primary usergroup (null: don't change) (you must handle updating group approvals manually)
 * @param  ?integer $dob_day Day of date of birth (null: don't change) (-1: unset)
 * @param  ?integer $dob_month Month of date of birth (null: don't change) (-1: unset)
 * @param  ?integer $dob_year Year of date of birth (null: don't change) (-1: unset)
 * @param  ?array $custom_fields A map of custom fields values, things specified are not  (field-id=>value) (null: don't change)
 * @param  ?ID_TEXT $timezone The member timezone (null: don't change)
 * @param  ?LANGUAGE_NAME $language The member's language (null: don't change)
 * @param  ?ID_TEXT $theme The member's default theme (null: don't change)
 * @param  ?SHORT_TEXT $title The member's title (blank: get from primary) (null: don't change)
 * @param  ?URLPATH $photo_url Photo URL (null: don't change)
 * @param  ?URLPATH $avatar_url Avatar (null: don't change)
 * @param  ?LONG_TEXT $signature Signature (null: don't change)
 * @param  ?BINARY $preview_posts Whether posts are previewed before they are made (null: don't change)
 * @param  ?BINARY $reveal_age Whether the member's age may be shown (null: don't change)
 * @param  ?BINARY $views_signatures Whether the member sees signatures in posts (null: don't change)
 * @param  ?BINARY $auto_monitor_contrib_content Whether the member automatically is enabled for notifications for content they contribute to (null: don't change)
 * @param  ?BINARY $smart_topic_notification Whether to do smart topic notification [i.e. avoid sending so many notifications] (null: don't change)
 * @param  ?BINARY $mailing_list_style Whether to send mailing-list style notifications (null: don't change)
 * @param  ?BINARY $auto_mark_read Mark topics as read automatically (null: don't change)
 * @param  ?BINARY $sound_enabled Whether sound is enabled (null: don't change)
 * @param  ?BINARY $allow_emails Whether the member allows e-mails via the site (null: don't change)
 * @param  ?BINARY $allow_emails_from_staff Whether the member allows e-mails from staff via the site (null: don't change)
 * @param  ?BINARY $highlighted_name Whether the member username will be highlighted (null: don't change)
 * @param  ?SHORT_TEXT $pt_allow Usergroups that may PT the member (null: don't change)
 * @param  ?LONG_TEXT $pt_rules_text Rules that other members must agree to before they may start a PT with the member (null: don't change)
 * @param  ?BINARY $validated Whether the account has been validated (null: do not change this) (null: don't change)
 * @param  ?TIME $probation_expiration_time When the member is on probation until (null: don't change)
 * @param  ?ID_TEXT $is_perm_banned Banned status (null: don't change)
 * @param  boolean $check_correctness Whether to check details for correctness and do most of the change-triggered e-mails
 * @param  ?ID_TEXT $password_compat_scheme Password compatibility scheme (null: don't change)
 * @param  ?SHORT_TEXT $salt Password salt (null: don't change) (blank: generate a new one depending on our password compat scheme)
 * @param  ?TIME $join_time When the member joined (null: don't change)
 * @param  ?boolean $sensitive_change_alert Whether to send an alert to the member that their login information has changed, if applicable (null: use the value of $check_correctness)
 */
function cns_edit_member(int $member_id, ?string $username = null, ?string $password = null, ?string $email_address = null, ?int $primary_group = null, ?int $dob_day = null, ?int $dob_month = null, ?int $dob_year = null, ?array $custom_fields = null, ?string $timezone = null, ?string $language = null, ?string $theme = null, ?string $title = null, ?string $photo_url = null, ?string $avatar_url = null, ?string $signature = null, ?int $preview_posts = null, ?int $reveal_age = null, ?int $views_signatures = null, ?int $auto_monitor_contrib_content = null, ?int $smart_topic_notification = null, ?int $mailing_list_style = null, ?int $auto_mark_read = null, ?int $sound_enabled = null, ?int $allow_emails = null, ?int $allow_emails_from_staff = null, ?int $highlighted_name = null, ?string $pt_allow = '*', ?string $pt_rules_text = '', ?int $validated = null, ?int $probation_expiration_time = null, ?string $is_perm_banned = null, bool $check_correctness = true, ?string $password_compat_scheme = null, ?string $salt = null, ?int $join_time = null, ?bool $sensitive_change_alert = null)
{
    // Cannot edit a member pending deletion
    if ($GLOBALS['CNS_DRIVER']->get_member_row_field($member_id, 'm_password_compat_scheme') == 'pending_deletion') {
        warn_exit(do_lang_tempcode('MEMBER_PENDING_DELETION'));
    }

    if ($sensitive_change_alert === null) {
        $sensitive_change_alert = $check_correctness;
    }

    require_code('type_sanitisation');
    require_code('cns_members_action');

    $update = [];

    $old_email_address = $GLOBALS['CNS_DRIVER']->get_member_row_field($member_id, 'm_email_address');
    $old_username = $GLOBALS['CNS_DRIVER']->get_member_row_field($member_id, 'm_username');

    // Check for invalid or used e-mails
    if ($check_correctness) {
        if ((!cms_empty_safe($email_address)) && ($email_address != STRING_MAGIC_NULL) && (!is_valid_email_address($email_address))) {
            warn_exit(do_lang_tempcode('_INVALID_EMAIL_ADDRESS', escape_html($email_address)));
        }

        if ((get_option('one_per_email_address') != '0') && ($email_address != '') && ($email_address != $old_email_address) && ($email_address != STRING_MAGIC_NULL)) {
            $test = $GLOBALS['FORUM_DB']->query_select_value_if_there('f_members', 'id', ['m_email_address' => $email_address]);
            if (($test !== null) && ($test != $member_id)) {
                warn_exit(do_lang_tempcode('_EMAIL_ADDRESS_IN_USE'));
            }
        }
    }

    // Check for valid username
    if ($username !== null && $check_correctness) {
        require_code('temporal');
        cns_check_name_valid($username, $member_id, $password, $email_address, ($dob_year === null) ? null : cms_mktime(12, 0, 0, $dob_month, $dob_day, $dob_year));

        require_code('urls2');
        suggest_new_idmoniker_for('members', 'view', strval($member_id), '', $username);
    }

    // Password change
    if ($password !== null) {
        // Invalidate any existing login key
        $update['m_login_key_hash'] = '';

        // Determine password scheme
        if ($password_compat_scheme === null) {
            if (get_value('disable_password_hashing') === '1') {
                $password_compat_scheme = 'plain';
            } else {
                $password_compat_scheme = $GLOBALS['CNS_DRIVER']->get_member_row_field($member_id, 'm_password_compat_scheme');
            }
        } else {
            $update['m_password_compat_scheme'] = $password_compat_scheme;
        }

        // Process some update code depending on our scheme
        $update['m_password_change_code'] = '';
        $update['m_password_change_code_time'] = null;
        switch ($password_compat_scheme) {
            case 'plain':
                $salt = '';
                $update['m_pass_salt'] = '';
                $password_hashed = $password;
                break;

            case 'md5':
                $salt = ''; // We don't support salting on software md5
                $update['m_pass_salt'] = '';
                $password_hashed = md5($password);
                break;

            case 'bcrypt':
            case 'bcrypt_temporary':
            case 'bcrypt_expired':
                require_code('crypt');

                if (($salt !== null) && ($salt != '')) {
                    $update['m_pass_salt'] = $salt;
                } else {
                    if ($salt === null) {
                        $salt = $GLOBALS['CNS_DRIVER']->get_member_row_field($member_id, 'm_pass_salt');
                    }
                    if ($salt == '') { // Generate a new salt
                        $salt = get_secure_random_string(32, CRYPT_BASE64);
                        $update['m_pass_salt'] = $salt;
                    }
                }
                $password_hashed = ratchet_hash($password, $salt);
                break;

            default: // Some special scheme; we assume $password is already hashed in this case
                $password_hashed = $password;
                if ($salt !== null) {
                    $update['m_pass_salt'] = $salt;
                }
        }
        $update['m_pass_hash_salted'] = $password_hashed;

        // Set when the password must be changed, if applicable
        $password_change_days = get_option('password_change_days');
        if (intval($password_change_days) > 0) {
            if ($password_compat_scheme == 'bcrypt') {
                require_code('password_rules');
                bump_password_change_date($member_id, $password, $check_correctness);

                $update['m_last_visit_time'] = time(); // Needed when an admin changing another password (but okay always): So that the password isn't assumed auto-expired, forcing them to reset it again
            }
        }
    }

    $changes = [];

    if ($custom_fields !== null) {
        $groups = $GLOBALS['CNS_DRIVER']->get_members_groups($member_id);
        $all_fields = cns_get_all_custom_fields_match(
            $groups, // groups
            (($member_id == get_member()) || (has_privilege(get_member(), 'view_any_profile_field'))) ? null : 1, // public view
            null, // owner view
            (($member_id != get_member()) || (has_privilege(get_member(), 'view_any_profile_field'))) ? null : 1, // owner set
            null, // required
            null, // show in posts
            null, // show in post previews
            null, // special start
            null // show on join form
        );

        $fields_to_skip = _cpfs_internal_use_only();

        $phone_number_field = find_cms_cpf_field_id('cms_mobile_phone_number');
        if ($phone_number_field !== null) {
            $phone_number = null;
            $old_phone_number = get_cms_cpf('mobile_phone_number', $member_id);
        }

        // Set Custom Profile Field values
        $all_fields_types = collapse_2d_complexity('id', 'cf_type', $all_fields);
        foreach ($custom_fields as $field_id => $value) {
            if (!array_key_exists($field_id, $all_fields_types)) {
                continue; // Trying to set a field we're not allowed to (doesn't apply to our group)
            }

            if (in_array($field_id, $fields_to_skip)) {
                continue; // Trying to set a field we're not allowed to (internal field)
            }

            if ($field_id === $phone_number_field) {
                // Log phone number changes
                if ($value != $old_phone_number) {
                    log_it('EDIT_MEMBER_PHONE_NUMBER', strval($member_id), $old_phone_number);
                }
                $phone_number = $value;
            }

            $change = cns_set_custom_field($member_id, $field_id, $value, $all_fields_types[$field_id], true);
            if ($change !== null) {
                $changes = array_merge($changes, $change);
            }
        }
        if (!empty($changes)) {
            $GLOBALS['FORUM_DB']->query_update('f_member_custom_fields', $changes, ['mf_member_id' => $member_id], '', 1);
        }
    }

    $old_primary_group = $GLOBALS['CNS_DRIVER']->get_member_row_field($member_id, 'm_primary_group');
    $_pt_rules_text = $GLOBALS['CNS_DRIVER']->get_member_row_field($member_id, 'm_pt_rules_text');
    $_signature = $GLOBALS['CNS_DRIVER']->get_member_row_field($member_id, 'm_signature');

    // Begin populating what needs updated
    if ($email_address !== null) {
        $update['m_email_address'] = $email_address;
    }

    $can_edit_birthday = cns_can_edit_birthday($member_id);
    if ($can_edit_birthday) {
        if ($dob_day !== null) {
            $update['m_dob_day'] = ($dob_day == -1) ? null : $dob_day;
        }
        if ($dob_month !== null) {
            $update['m_dob_month'] = ($dob_month == -1) ? null : $dob_month;
        }
        if ($dob_year !== null) {
            $update['m_dob_year'] = ($dob_year == -1) ? null : $dob_year;
        }
    }

    if ($timezone !== null) {
        $update['m_timezone_offset'] = $timezone;
    }
    if ($language !== null) {
        $update['m_language'] = $language;
    }
    if ($theme !== null) {
        $update['m_theme'] = $theme;
    }
    if ($photo_url !== null) {
        $update['m_photo_url'] = $photo_url;
    }
    if ($title !== null) {
        $update['m_title'] = $title;
    }
    if ($avatar_url !== null) {
        $update['m_avatar_url'] = $avatar_url;
    }
    if ($signature !== null) {
        $update += lang_remap_comcode('m_signature', $_signature, $signature, $GLOBALS['FORUM_DB']);
    }
    if ($preview_posts !== null) {
        $update['m_preview_posts'] = $preview_posts;
    }
    if ($reveal_age !== null) {
        $update['m_reveal_age'] = $reveal_age;
    }
    if ($views_signatures !== null) {
        $update['m_views_signatures'] = $views_signatures;
    }
    if ($auto_monitor_contrib_content !== null) {
        $update['m_auto_monitor_contrib_content'] = $auto_monitor_contrib_content;
    }
    if ($smart_topic_notification !== null) {
        $update['m_smart_topic_notification'] = $smart_topic_notification;
    }
    if ($mailing_list_style !== null) {
        $update['m_mailing_list_style'] = $mailing_list_style;
    }
    if ($auto_mark_read !== null) {
        $update['m_auto_mark_read'] = $auto_mark_read;
    }
    if ($sound_enabled !== null) {
        $update['m_sound_enabled'] = $sound_enabled;
    }
    $doing_email_option = (get_option('member_email_receipt_configurability') != '0') && (addon_installed('cns_contact_member'));
    if (($allow_emails !== null) && ($doing_email_option)) {
        $update['m_allow_emails'] = $allow_emails;
    }
    $doing_email_from_staff_option = (get_option('staff_email_receipt_configurability') != '0');
    if (($allow_emails_from_staff !== null) && ($doing_email_from_staff_option)) {
        $update['m_allow_emails_from_staff'] = $allow_emails_from_staff;
    }
    if ($pt_allow !== null) {
        $update['m_pt_allow'] = $pt_allow;
    }
    if ($pt_rules_text !== null) {
        $update += lang_remap_comcode('m_pt_rules_text', $_pt_rules_text, $pt_rules_text, $GLOBALS['FORUM_DB']);
    }
    if ((!$check_correctness) || (has_privilege(get_member(), 'probate_members'))) {
        $update['m_probation_expiration_time'] = $probation_expiration_time;
    }
    if ($is_perm_banned !== null) {
        $update['m_is_perm_banned'] = $is_perm_banned;
    }
    if ($join_time !== null) {
        $update['m_join_time'] = $join_time;
    }

    if (($username !== null) && ($old_username !== null) && ($username != $old_username) && ((!$check_correctness) || (has_actual_page_access(get_member(), 'admin_cns_members')) || (has_privilege($member_id, 'rename_self')))) { // Username change
        $update['m_username'] = $username;

        // Reassign personal galleries
        if (addon_installed('galleries')) {
            require_lang('galleries');
            $personal_galleries = $GLOBALS['SITE_DB']->query('SELECT name,fullname,parent_id FROM ' . get_table_prefix() . 'galleries WHERE name LIKE \'member\_' . strval($member_id) . '\_%\'');
            foreach ($personal_galleries as $gallery) {
                $parent_title = get_translated_text($GLOBALS['SITE_DB']->query_select_value('galleries', 'fullname', ['name' => $gallery['parent_id']]));
                if (get_translated_text($gallery['fullname']) == do_lang('PERSONAL_GALLERY_OF', $old_username, $parent_title)) {
                    $new_fullname = do_lang('PERSONAL_GALLERY_OF', $username, $parent_title);
                    $GLOBALS['SITE_DB']->query_update('galleries', lang_remap_comcode('fullname', $gallery['fullname'], $new_fullname), ['name' => $gallery['name']], '', 1);
                }
            }
        }

        // Update author profile
        if (addon_installed('news')) {
            $GLOBALS['SITE_DB']->query_update('news', ['author' => $username], ['author' => $old_username]);
        }

        update_member_username_caching($member_id, $username);

        log_it('EDIT_MEMBER_USERNAME', strval($member_id), $old_username);
    }
    if ($password !== null) { // Password change
        // Security, clear out sessions from other people on this user - just in case the reset is due to suspicious activity
        require_code('users_active_actions');
        delete_session_by_member_id($member_id, get_session_id());

        // Log the change
        log_it('EDIT_MEMBER_PASSWORD', strval($member_id));
    }
    if ($validated !== null) {
        $update['m_validated_email_confirm_code'] = '';
        if (addon_installed('validation')) {
            $update['m_validated'] = $validated;

            if (($validated == 1) && ($GLOBALS['CNS_DRIVER']->get_member_row_field($member_id, 'm_validated') == 0)) {
                $update['m_join_time'] = time(); // So welcome mails go out correctly
            }
        }
    }
    if ($highlighted_name !== null) {
        $update['m_highlighted_name'] = $highlighted_name;
    }
    if ($primary_group !== null) {
        $update['m_primary_group'] = $primary_group;

        if ($primary_group != $old_primary_group) {
            $GLOBALS['FORUM_DB']->query_insert('f_group_join_log', [
                'member_id' => $member_id,
                'usergroup_id' => $primary_group,
                'join_time' => time(),
            ]);

            log_it('MEMBER_PRIMARY_GROUP_CHANGED', strval($member_id), strval($primary_group));
        }
    }

    $join_time = $GLOBALS['FORUM_DRIVER']->get_member_row_field($member_id, 'm_join_time');

    $GLOBALS['FORUM_DB']->query_update('f_members', $update, ['id' => $member_id], '', 1);

    if (get_member() != $member_id) {
        log_it('EDIT_MEMBER_PROFILE', strval($member_id), $username);
    }

    // Send out an account validated e-mail if the member is being marked valid, and also log it
    $old_validated = $GLOBALS['CNS_DRIVER']->get_member_row_field($member_id, 'm_validated');
    if (($old_validated == 0) && ($validated == 1)) {
        require_code('mail');
        $_login_url = build_url(['page' => 'login'], get_module_zone('login'), [], false, false, true);
        $login_url = $_login_url->evaluate();

        // NB: Same mail also sent in settings.php (quick-validate feature)
        $vm_subject = do_lang('VALIDATED_MEMBER_SUBJECT', get_site_name(), null, get_lang($member_id));
        $vm_body = do_lang('MEMBER_VALIDATED', get_site_name(), $old_username, $login_url, get_lang($member_id));

        // Necessary to use dispatch_mail in case the member was locked out of their account
        dispatch_mail($vm_subject, $vm_body, '', [$email_address], $old_username, '', '', ['require_recipient_valid_since' => $join_time]);

        $current_username = $GLOBALS['FORUM_DRIVER']->get_username(get_member());
        log_it('VALIDATE_MEMBER', strval($member_id), $current_username);
    }

    // Update invites, and log, when e-mail is changed
    if ($old_email_address != $email_address) {
        $GLOBALS['FORUM_DB']->query_update('f_invites', ['i_email_address' => $old_email_address], ['i_email_address' => $email_address]);
        log_it('EDIT_MEMBER_EMAIL', strval($member_id), $old_email_address);
    }

    // E-mail and notify to inform of sensitive changes (username, password, e-mail, or phone number)
    $username_changed = ($username !== null) && ($username !== $old_username);
    $email_address_changed = ($email_address !== null) && ($email_address !== $old_email_address);
    $password_changed = ($password !== null);
    $phone_number_changed = ($custom_fields !== null) && ($phone_number_field !== null) && ($phone_number !== null) && ($old_phone_number !== $phone_number);
    if (($username_changed || $email_address_changed || $password_changed || $phone_number_changed)) {
        $current_username = $GLOBALS['FORUM_DRIVER']->get_username(get_member());

        $_sensitive_changes = [];
        if ($username_changed) {
            $_sensitive_changes[] = do_lang('SECURITY_ASPECT_CHANGED__USERNAME', comcode_escape($old_username), comcode_escape($username));
        }
        if ($email_address_changed) {
            require_code('crypt');

            // New e-mail should be masked in case the member's original e-mail account is compromised so hackers do not target / spam their new address
            $masked_email_address = mask_email_address($email_address);
            $_sensitive_changes[] = do_lang('SECURITY_ASPECT_CHANGED__EMAIL_ADDRESS', comcode_escape($old_email_address), comcode_escape($masked_email_address));
        }
        if ($password_changed) {
            $_sensitive_changes[] = do_lang('SECURITY_ASPECT_CHANGED__PASSWORD');
        }
        if ($phone_number_changed) {
            require_code('crypt');

            // Mask old and new phone number so hackers of compromised accounts cannot spam members' phones
            $masked_old_phone_number = mask_phone_number($old_phone_number);
            $masked_phone_number = mask_phone_number($phone_number);
            $_sensitive_changes[] = do_lang('SECURITY_ASPECT_CHANGED__PHONE_NUMBER', comcode_escape($masked_old_phone_number), comcode_escape($masked_phone_number));
        }
        $sensitive_changes = implode("\n", $_sensitive_changes);

        $part_b = '';
        if (!has_actual_page_access(get_member(), 'admin_cns_members')) { // If change not by an admin
            $part_b = do_lang('SECURITY_ASPECT_CHANGED_BODY_2', get_ip_address());
        }

        // Notify member e-mail addresses if specified
        if ((($old_email_address != '') || ($email_address !== null)) && ($sensitive_change_alert)) {
            require_code('mail');

            if ($old_email_address != '') { // E-mail of security aspects that were changed to the e-mail on file (old)
                $cm_subject = do_lang('SECURITY_ASPECT_CHANGED_SUBJECT', comcode_escape($old_username), comcode_escape($current_username), [get_site_name()]);
                $cm_body = do_lang('SECURITY_ASPECT_CHANGED_BODY', comcode_escape($old_username), comcode_escape($current_username), [get_site_name(), $sensitive_changes, $part_b]);
                dispatch_mail($cm_subject, $cm_body, do_lang('mail:NO_MAIL_WEB_VERSION__SENSITIVE'), [$old_email_address], $old_username, '', '', ['require_recipient_valid_since' => $join_time]);
            }
            if (($email_address !== null) && ($email_address !== $old_email_address)) { // When a new e-mail is specified, also e-mail the new address a vague message about their e-mail being associated with an account
                $cm_subject = do_lang('EMAIL_ASSOCIATED_SUBJECT', comcode_escape($old_username), comcode_escape($current_username), [get_site_name()]);
                $cm_body = do_lang('EMAIL_ASSOCIATED_BODY', comcode_escape($old_username), comcode_escape($current_username), [get_site_name(), comcode_escape($email_address)]);
                dispatch_mail($cm_subject, $cm_body, do_lang('mail:NO_MAIL_WEB_VERSION__SENSITIVE'), [$email_address], $old_username, '', '', ['require_recipient_valid_since' => $join_time]);
            }
        }

        // Notify staff
        require_code('notifications');

        $subject = do_lang('STAFF_SECURITY_ASPECT_CHANGED_SUBJECT', comcode_escape($old_username), comcode_escape($current_username), [get_site_name()], get_site_default_lang());
        $mail = do_notification_lang('STAFF_SECURITY_ASPECT_CHANGED_BODY', comcode_escape($old_username), comcode_escape($current_username), [comcode_escape(get_site_name()), comcode_escape($sensitive_changes), comcode_escape($part_b)], get_site_default_lang());
        dispatch_notification('cns_profile_high_impact_edit', null, $subject, $mail, null, get_member(), ['use_real_from' => true]);
    }

    delete_value('cns_newest_member_id');
    delete_value('cns_newest_member_username');

    // Decache from run-time cache
    unset($GLOBALS['FORUM_DRIVER']->MEMBER_ROWS_CACHED[$member_id]);
    unset($GLOBALS['MEMBER_CACHE_FIELD_MAPPINGS'][$member_id]);
    unset($GLOBALS['TIMEZONE_MEMBER_CACHE'][$member_id]);
    unset($GLOBALS['USER_NAME_CACHE'][$member_id]);
    unset($GLOBALS['USERS_GROUPS_CACHE'][$member_id]);
    unset($GLOBALS['GROUP_MEMBERS_CACHE'][$member_id]);

    if ((addon_installed('commandr')) && (!running_script('install')) && (!get_mass_import_mode())) {
        require_code('resource_fs');
        generate_resource_fs_moniker('member', strval($member_id));
    }

    delete_cache_entry('main_members');
    if (($GLOBALS['FORUM_DRIVER']->is_super_admin($member_id)) && ($old_email_address == '')) {
        delete_cache_entry('main_staff_checklist'); // As it tracks whether admin have e-mail address set
    }

    require_code('sitemap_xml');
    if ($validated == 1) {
        notify_sitemap_node_edit('_SEARCH:members:view:' . strval($member_id));
    } else {
        notify_sitemap_node_delete('_SEARCH:members:view:' . strval($member_id));
    }
}

/**
 * Delete a member.
 *
 * @param  MEMBER $member_id The ID of the member
 * @param  ?MEMBER $member_id_deleting The ID of the member doing the deleting (null: current member)
 */
function cns_delete_member(int $member_id, ?int $member_id_deleting = null)
{
    $info = $GLOBALS['FORUM_DB']->query_select('f_members', ['id'], ['id' => $member_id], '', 1);
    if (!array_key_exists(0, $info)) {
        warn_exit(do_lang_tempcode('MISSING_RESOURCE', 'member'));
    }

    if ($member_id_deleting === null) {
        $member_id_deleting = get_member();
    }

    $username = $GLOBALS['CNS_DRIVER']->get_member_row_field($member_id, 'm_username');
    $by_username = $GLOBALS['CNS_DRIVER']->get_member_row_field($member_id_deleting, 'm_username');
    $signature = $GLOBALS['CNS_DRIVER']->get_member_row_field($member_id, 'm_signature');
    $email_address = $GLOBALS['CNS_DRIVER']->get_member_row_field($member_id, 'm_email_address');

    require_code('attachments2');
    require_code('attachments3');
    delete_lang_comcode_attachments($signature, 'signature', strval($member_id), $GLOBALS['FORUM_DB']);
    $GLOBALS['FORUM_DB']->query_delete('f_members', ['id' => $member_id], '', 1);
    $GLOBALS['FORUM_DB']->query_delete('f_group_members', ['gm_member_id' => $member_id]);
    $GLOBALS['FORUM_DB']->query_update('f_groups', ['g_group_lead_member' => get_member()], ['g_group_lead_member' => $member_id]);
    require_code('users_active_actions');
    delete_session_by_member_id($member_id);

    require_code('fields');

    // Delete Custom Profile Fields
    $cpfs = $GLOBALS['FORUM_DB']->query_select('f_custom_fields');
    $fields_row = $GLOBALS['FORUM_DB']->query_select('f_member_custom_fields', ['*'], ['mf_member_id' => $member_id], '', 1);
    if (array_key_exists(0, $fields_row)) {
        foreach ($cpfs as $field) {
            $l = $fields_row[0]['field_' . strval($field['id'])];

            $object = get_fields_hook($field['cf_type']);

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

            if (method_exists($object, 'cleanup')) {
                $object->cleanup(['cv_value' => $l]);
            }

            if ((strpos($storage_type, '_trans') !== false) && ($l !== null)) {
                if (true) { // Always do this just in case it is for attachments
                    require_code('attachments2');
                    require_code('attachments3');
                    delete_lang_comcode_attachments($l, 'null', strval($member_id), $GLOBALS['FORUM_DB']);
                } else {
                    delete_lang($l, $GLOBALS['FORUM_DB']);
                }
            }
        }
    }
    $GLOBALS['FORUM_DB']->query_delete('f_member_custom_fields', ['mf_member_id' => $member_id], '', 1);

    // Cleanup images
    $old = $GLOBALS['CNS_DRIVER']->get_member_row_field($member_id, 'm_avatar_url');
    if ((url_is_local($old)) && ((substr($old, 0, 20) == 'uploads/cns_avatars/') || (substr($old, 0, 16) == 'uploads/avatars/'))) {
        @unlink(get_custom_file_base() . '/' . rawurldecode($old));
        sync_file(rawurldecode($old));
    }
    $old = $GLOBALS['CNS_DRIVER']->get_member_row_field($member_id, 'm_photo_url');
    if ((url_is_local($old)) && ((substr($old, 0, 19) == 'uploads/cns_photos/') || (substr($old, 0, 15) == 'uploads/photos/'))) {
        @unlink(get_custom_file_base() . '/' . rawurldecode($old));
        sync_file(rawurldecode($old));
    }

    if (addon_installed('catalogues')) {
        update_catalogue_content_ref('member', strval($member_id), '');
    }

    delete_value('cns_newest_member_id');
    delete_value('cns_newest_member_username');

    // Must use cns_mod_log_it instead of log_it because we need to define the member who did the deleting
    require_code('cns_general_action2');
    cns_mod_log_it('DELETE_MEMBER', strval($member_id), $username, '', $member_id_deleting);

    // E-mail the member to inform them their account was deleted
    if ($email_address != '') {
        $part_b = '';
        if (!has_actual_page_access(get_member(), 'admin_cns_members')) { // If change not by an admin
            $part_b = do_lang('SECURITY_ASPECT_CHANGED_BODY_2', get_ip_address());
        }

        require_code('mail');
        $dm_subject = do_lang('ACCOUNT_DELETED_SUBJECT', comcode_escape($username), comcode_escape($by_username), [get_site_name()]);
        $dm_body = do_lang('ACCOUNT_DELETED_BODY', comcode_escape($username), comcode_escape($by_username), [get_site_name(), comcode_escape($email_address), $part_b]);
        dispatch_mail($dm_subject, $dm_body, '', [$email_address], $username, '', '');
    }

    // Also notify staff
    require_code('notifications');
    require_lang('cns');
    $message = do_notification_lang('MEMBER_DELETED_MAIL', comcode_escape($username), comcode_escape($by_username));
    dispatch_notification('cns_member_deleted', null, do_lang('MEMBER_DELETED_SUBJECT', comcode_escape($username)), $message, null, $member_id_deleting);

    if ((addon_installed('commandr')) && (!running_script('install')) && (!get_mass_import_mode())) {
        require_code('resource_fs');
        expunge_resource_fs_moniker('member', strval($member_id));
    }

    delete_cache_entry('main_members');

    require_code('sitemap_xml');
    notify_sitemap_node_delete('_SEARCH:members:view:' . strval($member_id));
}

/**
 * Ban a member.
 *
 * @param  AUTO_LINK $member_id The ID of the member
 * @param  ID_TEXT $reasoned_ban The reasoned ban value ('1' is just a regular ban, the norm)
 * @param  boolean $automatic Whether it is an automatic ban
 */
function cns_ban_member(int $member_id, string $reasoned_ban = '1', bool $automatic = false)
{
    if ($reasoned_ban == '0') {
        fatal_exit(do_lang_tempcode('INTERNAL_ERROR'));
    }

    $previous_value = $GLOBALS['CNS_DRIVER']->get_member_row_field($member_id, 'm_is_perm_banned');

    $GLOBALS['FORUM_DB']->query_update('f_members', ['m_is_perm_banned' => $reasoned_ban], ['id' => $member_id], '', 1);

    if ($previous_value != '0') {
        return;
    }

    require_code('mail');

    $username = $GLOBALS['CNS_DRIVER']->get_member_row_field($member_id, 'm_username');
    $email_address = $GLOBALS['CNS_DRIVER']->get_member_row_field($member_id, 'm_email_address');
    $join_time = $GLOBALS['FORUM_DRIVER']->get_member_row_field($member_id, 'm_join_time');

    log_it($automatic ? 'BAN_MEMBER_AUTOMATIC' : 'BAN_MEMBER', strval($member_id), $username);

    require_lang('cns');
    $mail = do_lang('BAN_MEMBER_MAIL', $username, get_site_name(), [], get_lang($member_id));
    dispatch_mail(do_lang('BAN_MEMBER_MAIL_SUBJECT', null, null, null, get_lang($member_id)), $mail, '', [$email_address], $username, '', '', ['priority' => 2, 'require_recipient_valid_since' => $join_time]);

    delete_cache_entry('main_members');

    unset($GLOBALS['FORUM_DRIVER']->MEMBER_ROWS_CACHED[$member_id]);
}

/**
 * Unban a member.
 *
 * @param  AUTO_LINK $member_id The ID of the member
 */
function cns_unban_member(int $member_id)
{
    if ($GLOBALS['CNS_DRIVER']->get_member_row_field($member_id, 'm_is_perm_banned') == '0') {
        return;
    }

    $username = $GLOBALS['CNS_DRIVER']->get_member_row_field($member_id, 'm_username');
    $email_address = $GLOBALS['CNS_DRIVER']->get_member_row_field($member_id, 'm_email_address');
    $join_time = $GLOBALS['FORUM_DRIVER']->get_member_row_field($member_id, 'm_join_time');

    $GLOBALS['FORUM_DB']->query_update('f_members', ['m_is_perm_banned' => '0'], ['id' => $member_id], '', 1);

    log_it('UNBAN_MEMBER', strval($member_id), $username);

    require_code('mail');

    require_lang('cns');
    $mail = do_lang('UNBAN_MEMBER_MAIL', $username, get_site_name(), [], get_lang($member_id));
    dispatch_mail(do_lang('UNBAN_MEMBER_MAIL_SUBJECT', null, null, null, get_lang($member_id)), $mail, '', [$email_address], $username, '', '', ['priority' => 2, 'require_recipient_valid_since' => $join_time]);

    delete_cache_entry('main_members');

    unset($GLOBALS['FORUM_DRIVER']->MEMBER_ROWS_CACHED[$member_id]);
}

/**
 * Edit a Custom Profile Field.
 *
 * @param  AUTO_LINK $id The ID of the Custom Profile Field
 * @param  SHORT_TEXT $name Name of the field
 * @param  SHORT_TEXT $description Description of the field
 * @param  LONG_TEXT $default The default value for the field
 * @param  BINARY $public_view Whether the field is publicly viewable
 * @param  BINARY $owner_view Whether the field is viewable by the owner
 * @param  BINARY $owner_set Whether the field may be set by the owner
 * @param  BINARY $encrypted Whether the field should be encrypted
 * @param  BINARY $required Whether the field is to be shown on the join form
 * @param  BINARY $show_in_posts Whether this field is shown in posts and places where member details are highlighted (such as an image in a member gallery)
 * @param  BINARY $show_in_post_previews Whether this field is shown in preview places, such as in the forum member tooltip
 * @param  integer $order The order of this field relative to other fields
 * @param  LONG_TEXT $only_group The usergroups that this field is confined to (comma-separated list)
 * @param  ID_TEXT $type The type of the field
 * @set short_text long_text short_trans long_trans integer upload picture url list tick float
 * @param  BINARY $show_on_join_form Whether to show this field for filling in when a member joins the site
 * @param  SHORT_TEXT $options Field options
 * @param  BINARY $include_in_main_search Whether to include in main keyword search
 * @param  BINARY $allow_template_search Whether to allow template search
 * @param  ID_TEXT $icon An icon to show the CPF with on the member profiles
 * @param  ID_TEXT $section A section to show with on the member-links part of member profiles
 * @param  LONG_TEXT $tempcode This is Tempcode that is used for displaying the field. See the DESCRIPTION_CPF_CODE language string.
 * @param  ID_TEXT $autofill_type Autofill field name from https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofill-field
 * @param  ID_TEXT $autofill_hint Autofill hint: '' or 'shipping' or 'billing'
 */
function cns_edit_custom_field(int $id, string $name, string $description, string $default, int $public_view, int $owner_view, int $owner_set, int $encrypted, int $required, int $show_in_posts, int $show_in_post_previews, int $order, string $only_group, string $type, int $show_on_join_form, string $options, int $include_in_main_search, int $allow_template_search, string $icon, string $section, string $tempcode, string $autofill_type, string $autofill_hint)
{
    if ($only_group == '-1') {
        $only_group = '';
    }

    $info = $GLOBALS['FORUM_DB']->query_select('f_custom_fields', ['cf_name', 'cf_description'], ['id' => $id], '', 1);
    $_name = $info[0]['cf_name'];
    $_description = $info[0]['cf_description'];

    $map = [
        'cf_default' => $default,
        'cf_public_view' => $public_view,
        'cf_owner_view' => $owner_view,
        'cf_owner_set' => $owner_set,
        'cf_required' => $required,
        'cf_show_in_posts' => $show_in_posts,
        'cf_show_in_post_previews' => $show_in_post_previews,
        'cf_order' => $order,
        'cf_only_group' => $only_group,
        'cf_type' => $type,
        'cf_show_on_join_form' => $show_on_join_form,
        'cf_options' => $options,
        'cf_include_in_main_search' => $include_in_main_search,
        'cf_allow_template_search' => $allow_template_search,
        'cf_icon' => $icon,
        'cf_section' => $section,
        'cf_tempcode' => $tempcode,
        'cf_autofill_type' => $autofill_type,
        'cf_autofill_hint' => $autofill_hint,
    ];
    $map += lang_remap('cf_name', $_name, $name, $GLOBALS['FORUM_DB']);
    $map += lang_remap('cf_description', $_description, $description, $GLOBALS['FORUM_DB']);

    $GLOBALS['FORUM_DB']->query_update('f_custom_fields', $map, ['id' => $id], '', 1);

    require_code('cns_members_action');

    list($_type) = get_cpf_storage_for($type);

    if (is_object($GLOBALS['FORUM_DB']->connection_read)) {
        $smq = $GLOBALS['FORUM_DB']->strict_mode_query(false);
        if ($smq !== null) {
            $GLOBALS['FORUM_DB']->query($smq, null, 0, true); // Suppress errors in case access denied
        }
    }
    $GLOBALS['FORUM_DB']->alter_table_field('f_member_custom_fields', 'field_' . strval($id), $_type); // LEGACY: Field type should not have changed, but bugs can happen, especially between CMS versions, so we allow a CPF edit as a "fixup" op

    build_cpf_indices($id, $include_in_main_search == 1 || $allow_template_search == 1, $type, $_type);

    log_it('EDIT_CUSTOM_PROFILE_FIELD', strval($id), $name);

    if ((addon_installed('commandr')) && (!running_script('install')) && (!get_mass_import_mode())) {
        require_code('resource_fs');
        generate_resource_fs_moniker('cpf', strval($id));
    }

    if (function_exists('persistent_cache_delete')) {
        persistent_cache_delete('CUSTOM_FIELD_CACHE');
        persistent_cache_delete('LIST_CPFS');
    }

    delete_cache_entry('main_members');
}

/**
 * Delete a Custom Profile Field.
 *
 * @param  AUTO_LINK $id The ID of the Custom Profile Field
 */
function cns_delete_custom_field(int $id)
{
    $info = $GLOBALS['FORUM_DB']->query_select('f_custom_fields', ['cf_name', 'cf_description'], ['id' => $id], '', 1);
    if (!array_key_exists(0, $info)) {
        warn_exit(do_lang_tempcode('MISSING_RESOURCE', 'cpf'));
    }
    $_name = $info[0]['cf_name'];
    $_description = $info[0]['cf_description'];

    delete_lang($_name, $GLOBALS['FORUM_DB']);
    delete_lang($_description, $GLOBALS['FORUM_DB']);
    $GLOBALS['FORUM_DB']->delete_index_if_exists('f_member_custom_fields', 'mcf' . strval($id));
    $GLOBALS['FORUM_DB']->delete_index_if_exists('f_member_custom_fields', '#mcf_ft_' . strval($id));
    $GLOBALS['FORUM_DB']->delete_table_field('f_member_custom_fields', 'field_' . strval($id));
    $GLOBALS['FORUM_DB']->query_delete('f_custom_fields', ['id' => $id], '', 1);

    global $TABLE_LANG_FIELDS_CACHE;
    unset($TABLE_LANG_FIELDS_CACHE['f_member_custom_fields']);

    log_it('DELETE_CUSTOM_PROFILE_FIELD', strval($id), get_translated_text($_name, $GLOBALS['FORUM_DB']));

    if ((addon_installed('commandr')) && (!running_script('install')) && (!get_mass_import_mode())) {
        require_code('resource_fs');
        expunge_resource_fs_moniker('cpf', strval($id));
    }

    if (function_exists('persistent_cache_delete')) {
        persistent_cache_delete('CUSTOM_FIELD_CACHE');
        persistent_cache_delete('LIST_CPFS');
    }

    if (function_exists('delete_cache_entry')) {
        delete_cache_entry('main_members');
    }
}

/**
 * Set a Custom Profile Field for a member.
 *
 * @param  MEMBER $member_id The member
 * @param  AUTO_LINK $field_id The field being set
 * @param  mixed $value The value of the field. For a trans-type field, this can be either a lang-ID to be copied (from forum DB), or an actual string.
 * @param  ?ID_TEXT $type The field type (null: look it up)
 * @param  boolean $defer Whether to defer the change, by returning a result change rather than doing it right away
 * @return ?array Mapping change (null: none / can't defer)
 */
function cns_set_custom_field(int $member_id, int $field_id, $value, ?string $type = null, bool $defer = false) : ?array
{
    if ($value === STRING_MAGIC_NULL) {
        return null;
    }

    if ($type === null) {
        $type = $GLOBALS['FORUM_DB']->query_select_value('f_custom_fields', 'cf_type', ['id' => $field_id]);
    }

    require_code('cns_members');
    cns_get_custom_field_mappings($member_id); // This will do an auto-repair if CPF storage row is missing

    $db_fieldname = 'field_' . strval($field_id);

    global $ANY_FIELD_ENCRYPTED;
    if ($ANY_FIELD_ENCRYPTED === null) {
        $ANY_FIELD_ENCRYPTED = ($GLOBALS['FORUM_DB']->query_select_value_if_there('f_custom_fields', 'cf_encrypted', ['cf_encrypted' => 1]) !== null);
    }

    if ($ANY_FIELD_ENCRYPTED) {
        $encrypted = $GLOBALS['FORUM_DB']->query_select_value('f_custom_fields', 'cf_encrypted', ['id' => $field_id]);
        if (($encrypted) && (is_string($value))) {
            require_code('encryption');
            if (is_encryption_enabled()) {
                $current = $GLOBALS['FORUM_DB']->query_select_value_if_there('f_member_custom_fields', $db_fieldname, ['mf_member_id' => $member_id]);
                if ($current === null) {
                    return null;
                }
                if ((is_data_encrypted($current)) && (remove_magic_encryption_marker($value) == remove_magic_encryption_marker($current))) {
                    return null;
                }
                $value = encrypt_data($value);
            }
        }
    } else {
        $encrypted = false;
    }

    require_code('fields');
    $ob = get_fields_hook($type);
    list(, , $storage_type) = $ob->get_field_value_row_bits(['id' => $field_id, 'cf_default' => '', 'cf_type' => $type]);

    static $done_one_posting_field = false;

    if (strpos($storage_type, '_trans') !== false) {
        if (is_integer($value)) {
            $value = get_translated_text($value, $GLOBALS['FORUM_DB']);
        }

        $map = [];

        $current = $GLOBALS['FORUM_DB']->query_select_value_if_there('f_member_custom_fields', $db_fieldname, ['mf_member_id' => $member_id]);
        if ($current === null) {
            if (($type == 'posting_field') && (!$done_one_posting_field)) {
                $done_one_posting_field = true;
                require_code('attachments2');
                $map += insert_lang_comcode_attachments($db_fieldname, 3, $value, 'null', strval($member_id), $GLOBALS['FORUM_DB']);
            } else {
                $map += insert_lang_comcode($db_fieldname, $value, 3, $GLOBALS['FORUM_DB']);
            }

            $GLOBALS['FORUM_DB']->query_update('f_member_custom_fields', $map, ['mf_member_id' => $member_id], '', 1);
        } else {
            if (($type == 'posting_field') && (!$done_one_posting_field)) {
                $done_one_posting_field = true;
                require_code('attachments2');
                require_code('attachments3');
                $map += update_lang_comcode_attachments($db_fieldname, $current, $value, 'null', strval($member_id), $GLOBALS['FORUM_DB'], $member_id);
            } else {
                $map += lang_remap_comcode($db_fieldname, $current, $value, $GLOBALS['FORUM_DB']);
            }

            $GLOBALS['FORUM_DB']->query_update('f_member_custom_fields', $map, ['mf_member_id' => $member_id], '', 1);
        }

        $ret = null;
    } else {
        $change = [];

        if (is_string($value)) {
            switch ($storage_type) {
                case 'short_trans':
                case 'long_trans':
                    $change += insert_lang($db_fieldname, $value, 3, $GLOBALS['FORUM_DB']);
                    break;
                case 'integer':
                    $change[$db_fieldname] = ($value == '') ? null : intval($value);
                    break;
                case 'float':
                    $change[$db_fieldname] = ($value == '') ? null : floatval($value);
                    break;
                default:
                    $change[$db_fieldname] = $value;
                    break;
            }
        } elseif ($value === null) {
            switch ($storage_type) {
                case 'integer':
                case 'float':
                    $change[$db_fieldname] = $value;
                    break;
            }
        } else {
            $change[$db_fieldname] = $value;
        }

        if (!$defer) {
            $GLOBALS['FORUM_DB']->query_update('f_member_custom_fields', $change, ['mf_member_id' => $member_id], '', 1);
        }

        $ret = $change;
    }

    if (function_exists('delete_cache_entry')) {
        delete_cache_entry('main_members');
    }

    global $MEMBER_CACHE_FIELD_MAPPINGS;
    if (isset($MEMBER_CACHE_FIELD_MAPPINGS, $MEMBER_CACHE_FIELD_MAPPINGS[$member_id])) {
        unset($MEMBER_CACHE_FIELD_MAPPINGS[$member_id]);
    }

    return $ret;
}

/**
 * Check a username is valid for adding, and possibly also the password.
 *
 * @param  ?SHORT_TEXT $username The username (may get altered) (null: nothing to check)
 * @param  ?MEMBER $member_id The member (null: member not actually added yet; this ID is only given for the duplication check, to make sure it doesn't think we are duplicating with ourself)
 * @param  ?SHORT_TEXT $password The password (null: nothing to check)
 * @param  ?EMAIL $email_address The e-mail address that will go with the password (null: unknown)
 * @param  ?TIME $dob The date of birth that will go with the password (null: unknown)
 * @param  boolean $return_errors Whether to return errors instead of dying on them
 * @return ?Tempcode Error (null: none)
 */
function cns_check_name_valid(?string &$username, ?int $member_id = null, ?string $password = null, ?string $email_address = null, ?int $dob = null, bool $return_errors = false) : ?object
{
    // Check it doesn't already exist
    if ($username !== null) {
        $test = ($member_id === null) ? null : $GLOBALS['FORUM_DB']->query_select_value_if_there('f_members', 'id', ['m_username' => $username, 'id' => $member_id]); // Precedence on an ID match in case there are duplicate usernames and user is trying to fix that
        if ($test === null) {
            $test = $GLOBALS['FORUM_DB']->query_select_value_if_there('f_members', 'id', ['m_username' => $username]);
        }
        $error = do_lang_tempcode('USERNAME_ALREADY_EXISTS');
        if (($test !== null) && ($test !== $member_id)) {
            if ($return_errors) {
                return $error;
            }
            warn_exit($error);
        }
        $username_known_available = ($test === null);
    } else {
        $username_known_available = false;
    }

    if ($username !== null) {
        // Check for disallowed symbols in username
        $disallowed_characters = [/*'<','>','&','"',"'",'$',','*/]; // Actually we can tolerate this stuff
        foreach ($disallowed_characters as $disallowed_character) {
            if ((strpos($username, $disallowed_character) !== false) && ($username_known_available)) {
                $error = do_lang_tempcode('USERNAME_BAD_SYMBOLS', escape_html($disallowed_character));
                if ($return_errors) {
                    return $error;
                }
                warn_exit($error);
            }
        }
        if ((strpos($username, '@') !== false) && (strpos($username, '.') !== false) && ($username_known_available)) {
            $error = do_lang_tempcode('USERNAME_BAD_SYMBOLS', escape_html('@ / .'));
            if ($return_errors) {
                return $error;
            }
            warn_exit($error);
        }
    }

    // Check lengths
    if ($username !== null) {
        if (get_page_name() != 'admin_cns_members') {
            $_maximum_username_length = get_option('maximum_username_length');
            $maximum_username_length = min(100, intval($_maximum_username_length));
        } else {
            $maximum_username_length = 100;
        }
        if ((cms_mb_strlen($username) > $maximum_username_length) && ($username_known_available)) {
            $error = do_lang_tempcode('USERNAME_TOO_LONG', escape_html(integer_format($maximum_username_length)));
            if ($return_errors) {
                return $error;
            }
            warn_exit($error);
        }
        if (get_page_name() != 'admin_cns_members') {
            $_minimum_username_length = get_option('minimum_username_length');
            $minimum_username_length = max(1, intval($_minimum_username_length));
        } else {
            $minimum_username_length = 1;
        }
        if ((cms_mb_strlen($username) < $minimum_username_length) && ($username_known_available)) {
            $error = do_lang_tempcode('USERNAME_TOO_SHORT', escape_html(integer_format($minimum_username_length)));
            if ($return_errors) {
                return $error;
            }
            warn_exit($error);
        }
    }
    if (get_page_name() != 'admin_cns_members') {
        if ($password !== null) {
            require_code('password_rules');
            $test = check_password_complexity($password, ($username === null) ? '' : $username, ($email_address === null) ? '' : $email_address, $dob, $return_errors);
            if ($test !== null) {
                return $test;
            }
        }
    }

    // Check for whitespace
    if ($username !== null) {
        $prohibit_username_whitespace = get_option('prohibit_username_whitespace');
        if (($prohibit_username_whitespace === '1') && (cms_preg_match_safe('#\s#', $username) != 0) && ($username_known_available)) {
            $error = do_lang_tempcode('USERNAME_PASSWORD_WHITESPACE');
            if ($return_errors) {
                return $error;
            }
            warn_exit($error);
        }
    }
    if ($password !== null) {
        $prohibit_password_whitespace = get_option('prohibit_password_whitespace');
        if (($prohibit_password_whitespace === '1') && (cms_preg_match_safe('#\s#', $password) != 0) && ($username_known_available)) {
            $error = do_lang_tempcode('USERNAME_PASSWORD_WHITESPACE');
            if ($return_errors) {
                return $error;
            }
            warn_exit($error);
        }
    }

    // Check against restricted usernames
    if ((get_page_name() != 'admin_cns_members') && ($username_known_available)) {
        $restricted_usernames = explode(',', get_option('restricted_usernames'));
        $restricted_usernames[] = do_lang('GUEST');
        $restricted_usernames[] = do_lang('UNKNOWN');
        $restricted_usernames[] = do_lang('DELETED');
        $restricted_usernames[] = do_lang('SYSTEM');
        foreach ($restricted_usernames as $_restricted_username) {
            $restricted_username = trim($_restricted_username);
            if ($restricted_username == '') {
                continue;
            }
            if (strpos($username, $restricted_username) !== false) {
                $error = do_lang_tempcode('USERNAME_BAD_SUBSTRING', escape_html($restricted_username));
                if ($return_errors) {
                    return $error;
                }
                warn_exit($error);
            }
        }
    }

    // Check it is not numeric
    if (is_numeric($username)) {
        $error = do_lang_tempcode('USERNAME_NUMERIC');
        if ($return_errors) {
            return $error;
        }
        warn_exit($error);
    }

    return null;
}

/**
 * Edit a member's personal title, and check validity.
 *
 * @param  SHORT_TEXT $new_title The new title
 * @param  ?MEMBER $member_id The member (null: the current member)
 */
function cns_member_choose_title(string $new_title, ?int $member_id = null)
{
    if (!addon_installed('cns_member_titles')) {
        return;
    }

    if ($member_id === null) {
        $member_id = get_member();
    }

    $old_title = $GLOBALS['CNS_DRIVER']->get_member_row_field($member_id, 'm_title');
    if ($old_title == $new_title) {
        return;
    }

    if (cms_mb_strlen($new_title) > intval(get_option('max_member_title_length'))) {
        warn_exit(do_lang_tempcode('MEMBER_TITLE_TOO_BIG'));
    }

    $GLOBALS['FORUM_DB']->query_update('f_members', ['m_title' => $new_title], ['id' => $member_id], '', 1);

    // Decache from run-time cache
    unset($GLOBALS['FORUM_DRIVER']->MEMBER_ROWS_CACHED[$member_id]);
    unset($GLOBALS['MEMBER_CACHE_FIELD_MAPPINGS'][$member_id]);

    delete_cache_entry('main_members');
}

/**
 * Edit a member's signature, and check validity.
 *
 * @param  LONG_TEXT $new_signature The new signature
 * @param  ?MEMBER $member_id The member (null: the current member)
 */
function cns_member_choose_signature(string $new_signature, ?int $member_id = null)
{
    if ($member_id === null) {
        $member_id = get_member();
    }

    $max_sig_length = cns_get_member_best_group_property($member_id, 'max_sig_length_comcode');
    if (cms_mb_strlen($new_signature) > $max_sig_length) {
        require_lang('cns');
        warn_exit(make_string_tempcode(escape_html(do_lang('SIGNATURE_TOO_BIG'))));
    }

    $_signature = $GLOBALS['CNS_DRIVER']->get_member_row_field($member_id, 'm_signature');

    if (get_translated_text($_signature, $GLOBALS['FORUM_DB']) == $new_signature) {
        return;
    }

    require_code('attachments2');
    require_code('attachments3');
    $map = [];
    $map += update_lang_comcode_attachments('m_signature', $_signature, $new_signature, 'cns_signature', strval($member_id), $GLOBALS['FORUM_DB'], $member_id);
    $GLOBALS['FORUM_DB']->query_update('f_members', $map, ['id' => $member_id], '', 1);

    require_code('notifications');
    $subject = do_lang('CHOOSE_SIGNATURE_SUBJECT', $GLOBALS['FORUM_DRIVER']->get_username($member_id, true), $GLOBALS['FORUM_DRIVER']->get_username($member_id), null, get_lang($member_id));
    $body = do_notification_lang('CHOOSE_SIGNATURE_BODY', $new_signature, $GLOBALS['FORUM_DRIVER']->get_username($member_id), $GLOBALS['FORUM_DRIVER']->get_username($member_id, true), get_lang($member_id));
    dispatch_notification('cns_profile_high_impact_edit', null, $subject, $body, null, get_member(), ['use_real_from' => true]);

    // Decache from run-time cache
    unset($GLOBALS['FORUM_DRIVER']->MEMBER_ROWS_CACHED[$member_id]);
    unset($GLOBALS['MEMBER_CACHE_FIELD_MAPPINGS'][$member_id]);
}

/**
 * Edit a member's avatar, and check validity.
 *
 * @param  URLPATH $avatar_url The new avatar URL
 * @param  ?MEMBER $member_id The member (null: the current member)
 */
function cns_member_choose_avatar(string $avatar_url, ?int $member_id = null)
{
    if ($member_id === null) {
        $member_id = get_member();
    }

    $old = $GLOBALS['FORUM_DB']->query_select_value('f_members', 'm_avatar_url', ['id' => $member_id]);
    if ($old == $avatar_url) {
        return;
    }

    // Check it has valid dimensions
    if ($avatar_url != '') {
        require_code('images');
        if (!is_image($avatar_url, IMAGE_CRITERIA_WEBSAFE, has_privilege($member_id, 'comcode_dangerous'), true)) {
            $ext = get_file_extension($avatar_url);
            warn_exit(do_lang_tempcode('UNKNOWN_FORMAT', escape_html($ext)));
        }
        $stub = url_is_local($avatar_url) ? (get_complex_base_url($avatar_url) . '/') : '';
        $file_path_stub = convert_url_to_path($stub . $avatar_url);
        if ($file_path_stub !== null) {
            $from_file = @strval(cms_file_get_contents_safe($file_path_stub, FILE_READ_LOCK));
        } else {
            $from_file = http_get_contents($stub . $avatar_url, ['byte_limit' => 1024 * 1024 * 4/*reasonable limit*/, 'triger_error' => false]);
        }
        $test = cms_getimagesizefromstring($from_file, get_file_extension($avatar_url));
        if (($test !== null) && ($test[0] !== null) && ($test[1] !== null)) { // If we can get a size (if we can't it could mean many things - e.g. vector, missing, corrupt)
            list($sx, $sy) = $test;

            require_code('cns_groups');
            $width = cns_get_member_best_group_property($member_id, 'max_avatar_width');
            $height = cns_get_member_best_group_property($member_id, 'max_avatar_height');
            if (($sx > $width) || ($sy > $height)) {
                // Size down, if possible
                require_code('images');
                if ((!is_image($avatar_url, IMAGE_CRITERIA_GD_WRITE)) || (!url_is_local($avatar_url)) || (substr($avatar_url, 0, 20) != 'uploads/cns_avatars/')) {
                    if ((url_is_local($avatar_url)) && (substr($avatar_url, 0, 20) == 'uploads/cns_avatars/')) {
                        unlink(get_custom_file_base() . '/' . rawurldecode($avatar_url));
                        sync_file(get_custom_file_base() . '/' . rawurldecode($avatar_url));
                    }
                    warn_exit(do_lang_tempcode('IMAGE_BAD_DIMENSIONS', escape_html(strval($width) . 'x' . strval($height)), escape_html(strval($sx) . 'x' . strval($sy))));
                }
                $file_path = get_custom_file_base() . '/' . rawurldecode($avatar_url);
                $avatar_url = convert_image($file_path, $file_path, $width, $height, null, false, get_file_extension($file_path), true, true);
            }
        }

        if ((substr($avatar_url, 0, 7) != 'themes/') && (addon_installed('cns_avatars'))) {
            require_code('notifications');
            $subject = do_lang('CHOOSE_AVATAR_SUBJECT', $GLOBALS['FORUM_DRIVER']->get_username($member_id, true), $GLOBALS['FORUM_DRIVER']->get_username($member_id), null, get_lang($member_id));
            $body = do_notification_lang('CHOOSE_AVATAR_BODY', $stub . $avatar_url, $GLOBALS['FORUM_DRIVER']->get_username($member_id), $GLOBALS['FORUM_DRIVER']->get_username($member_id, true), get_lang($member_id));
            dispatch_notification('cns_profile_high_impact_edit', null, $subject, $body, null, get_member(), ['use_real_from' => true]);
        }
    }

    // Cleanup old avatar
    if ((url_is_local($old)) && ((substr($old, 0, 20) == 'uploads/cns_avatars/') || (substr($old, 0, 16) == 'uploads/avatars/')) && ($old != $avatar_url)) {
        @unlink(get_custom_file_base() . '/' . rawurldecode($old));
        sync_file(rawurldecode($old));
    }

    $GLOBALS['FORUM_DB']->query_update('f_members', ['m_avatar_url' => $avatar_url], ['id' => $member_id], '', 1);

    // Decache from run-time cache
    unset($GLOBALS['FORUM_DRIVER']->MEMBER_ROWS_CACHED[$member_id]);
    unset($GLOBALS['MEMBER_CACHE_FIELD_MAPPINGS'][$member_id]);

    delete_cache_entry('main_friends_list');

    delete_cache_entry('main_members');
}

/**
 * Edit a member's photo, and check validity.
 *
 * @param  ID_TEXT $param_name The identifier for the name of the posted URL field
 * @param  ID_TEXT $upload_name The identifier for the name of the posted upload
 * @param  ?MEMBER $member_id The member (null: the current member)
 */
function cns_member_choose_photo(string $param_name, string $upload_name, ?int $member_id = null)
{
    if ($member_id === null) {
        $member_id = get_member();
    }

    require_code('uploads');

    if (((!array_key_exists($upload_name, $_FILES)) || ((!is_plupload()) && (!is_uploaded_file($_FILES[$upload_name]['tmp_name']))))) {
        $old = $GLOBALS['FORUM_DB']->query_select_value('f_members', 'm_photo_url', ['id' => $member_id]);
        $x = post_param_string($param_name, '', INPUT_FILTER_URL_GENERAL);
        if (($x != '') && (url_is_local($x))) {
            if (!$GLOBALS['FORUM_DRIVER']->is_super_admin(get_member())) {
                if ($old != $x) {
                    access_denied('ASSOCIATE_EXISTING_FILE');
                }
            }
        }
        if ($old == $x) {
            return; // Not changed, bomb out as we don't need to do anything more
        }
    }

    // Find photo URL
    set_images_cleanup_pipeline_settings(IMG_RECOMPRESS_LOSSLESS, null, null, true); // Code to strip GPS
    $urls = get_url($param_name, $upload_name, file_exists(get_custom_file_base() . '/uploads/photos') ? 'uploads/photos' : 'uploads/cns_photos', OBFUSCATE_NEVER, CMS_UPLOAD_IMAGE, true, '', '', false, true);
    reset_images_cleanup_pipeline_settings();
    if (((get_base_url() != get_forum_base_url()) || (!empty($GLOBALS['SITE_INFO']['on_msn']))) && ($urls[0] != '') && (url_is_local($urls[0]))) {
        $urls[0] = get_base_url() . '/' . $urls[0];
    }

    cns_member_choose_photo_concrete($urls[0], $member_id);

    delete_cache_entry('main_members');
}

/**
 * Edit a member's photo.
 *
 * @param  URLPATH $url URL to photo
 * @param  ?MEMBER $member_id The member (null: the current member)
 */
function cns_member_choose_photo_concrete(string $url, ?int $member_id = null)
{
    if ($member_id === null) {
        $member_id = get_member();
    }

    // Cleanup old photo
    $old = $GLOBALS['FORUM_DB']->query_select_value('f_members', 'm_photo_url', ['id' => $member_id]);
    if ($old == $url) {
        return;
    }
    if ((url_is_local($old)) && ((substr($old, 0, 19) == 'uploads/cns_photos/') || (substr($old, 0, 15) == 'uploads/photos/'))) {
        sync_file(rawurldecode($old));
        @unlink(get_custom_file_base() . '/' . rawurldecode($old));
    }

    $GLOBALS['FORUM_DB']->query_update('f_members', ['m_photo_url' => $url], ['id' => $member_id], '', 1);

    require_code('notifications');
    $subject = do_lang('CHOOSE_PHOTO_SUBJECT', $GLOBALS['FORUM_DRIVER']->get_username($member_id, true), $GLOBALS['FORUM_DRIVER']->get_username($member_id), null, get_lang($member_id));
    $body = do_notification_lang('CHOOSE_PHOTO_BODY', $url, $GLOBALS['FORUM_DRIVER']->get_username($member_id), [$GLOBALS['FORUM_DRIVER']->get_username($member_id, true)], get_lang($member_id));
    dispatch_notification('cns_profile_high_impact_edit', null, $subject, $body, null, get_member(), ['use_real_from' => true]);

    // If Avatars addon not installed, use photo for it
    if (!addon_installed('cns_avatars')) {
        $avatar_url = $url;

        $stub = url_is_local($avatar_url) ? (get_complex_base_url($avatar_url) . '/') : '';
        $file_path = convert_url_to_path($stub . $avatar_url);
        if ($file_path !== null) {
            $new_file_path = str_replace('/cns_photos/', '/cns_avatars/', $file_path);
            if (!file_exists($new_file_path)) {
                copy($file_path, $new_file_path);
                fix_permissions($new_file_path);
                sync_file($new_file_path);
            }
            $avatar_url = str_replace('/cns_photos/', '/cns_avatars/', $avatar_url);
        }

        cns_member_choose_avatar($avatar_url, $member_id);
    }

    // Decache from run-time cache
    unset($GLOBALS['FORUM_DRIVER']->MEMBER_ROWS_CACHED[$member_id]);
    unset($GLOBALS['MEMBER_CACHE_FIELD_MAPPINGS'][$member_id]);

    delete_cache_entry('main_friends_list');

    delete_cache_entry('main_members');
}

/**
 * Update caching against a member's username. This doesn't change the username in the actual member record -- it is assumed that this will be done elsewhere.
 *
 * @param  MEMBER $member_id The member ID
 * @param  ID_TEXT $username The new username that is being set for them
 */
function update_member_username_caching(int $member_id, string $username)
{
    // Fix caching for usernames
    $to_fix = [
        'f_forums/f_cache_last_username/f_cache_last_member_id',
        'f_posts/p_poster_name_if_guest/p_posting_member',
        'f_topics/t_cache_first_username/t_cache_first_member_id',
        'f_topics/t_cache_last_username/t_cache_last_member_id',
        'sessions/cache_username/member_id',
    ];
    foreach ($to_fix as $fix) {
        list($table, $field, $updating_field) = explode('/', $fix, 3);
        $db = get_db_for($table);
        $db->query_update($table, [$field => $username], [$updating_field => $member_id]);
    }
}

/**
 * Get details of predefined templated fields.
 *
 * @return array List of predefined templated fields, each being a map
 */
function cns_predefined_custom_field_details() : array
{
    return [
        'sn_twitter' => [
            'type' => 'codename',
            'icon' => 'icons/links/twitter',
            'section' => '',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="http://twitter.com/{RAW*}" rel="me">{NAME*}</a>',
        ],
        'im_skype' => [
            'type' => 'codename',
            'icon' => 'icons/links/skype',
            'section' => 'contact',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="skype:{RAW*}?call">{NAME*}</a>',
        ],
        'im_jabber' => [
            'type' => 'codename',
            'icon' => 'icons/links/jabber',
            'section' => 'contact',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="xmpp:{RAW*}">{NAME*}</a>',
        ],
        'im_discord' => [
            'type' => 'codename',
            'icon' => 'icons/links/discord',
            'section' => 'contact',
            'tempcode' => '{NAME*}: {RAW*}',
        ],
        'github' => [
            'type' => 'codename',
            'icon' => 'icons/links/github',
            'section' => '',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="https://github.com/{RAW*}" rel="me">{NAME*}</a>',
        ],
        'gitlab' => [
            'type' => 'codename',
            'icon' => 'icons/links/gitlab',
            'section' => '',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="https://gitlab.com/{RAW*}" rel="me">{NAME*}</a>',
        ],
        'sn_instagram' => [
            'type' => 'codename',
            'icon' => 'icons/links/instagram',
            'section' => '',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="https://www.instagram.com/{RAW*}" rel="me">{NAME*}</a>',
        ],
        'sn_tiktok' => [
            'type' => 'codename',
            'icon' => 'icons/links/tiktok',
            'section' => '',
            'tempcode' => '{NAME*}: {RAW*}',
        ],
        'sn_minds' => [
            'type' => 'codename',
            'icon' => 'icons/links/minds',
            'section' => '',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="https://www.minds.com/{RAW*}" rel="me">{NAME*}</a>',
        ],
        'sn_pinterest' => [
            'type' => 'codename',
            'icon' => 'icons/links/pinterest',
            'section' => '',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="https://www.pinterest.com/{RAW*}" rel="me">{NAME*}</a>',
        ],
        'sn_snapchat' => [
            'type' => 'codename',
            'icon' => 'icons/links/snapchat',
            'section' => '',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="https://snapchat.com/add/{RAW*}" rel="me">{NAME*}</a>',
        ],
        'soundcloud' => [
            'type' => 'codename',
            'icon' => 'icons/links/soundcloud',
            'section' => '',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="https://soundcloud.com/{RAW*}" rel="me">{NAME*}</a>',
        ],
        'im_telegram' => [
            'type' => 'codename',
            'icon' => 'icons/links/telegram',
            'section' => 'contact',
            'tempcode' => '{NAME*}: {RAW*}',
        ],
        'sn_tumblr' => [
            'type' => 'codename',
            'icon' => 'icons/links/tumblr',
            'section' => '',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="https://{RAW*}.tumblr.com" rel="me">{NAME*}</a>',
        ],
        'sn_twitch' => [
            'type' => 'codename',
            'icon' => 'icons/links/twitch',
            'section' => '',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="https://www.twitch.tv/{RAW*}" rel="me">{NAME*}</a>',
        ],
        'im_whatsapp' => [
            'type' => 'codename',
            'icon' => 'icons/links/whatsapp',
            'section' => 'contact',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="whatsapp://send?phone={RAW*}" rel="me">{NAME*}</a>',
        ],
        'sn_sina_weibo' => [
            'type' => 'codename',
            'icon' => 'icons/links/sina_weibo',
            'section' => '',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="http://weibo.com/{RAW*}" rel="me">{NAME*}</a>',
        ],
        'im_wechat' => [
            'type' => 'codename',
            'icon' => 'icons/links/wechat',
            'section' => 'contact',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="http://weixin.qq.com/r/{RAW*}" rel="me">{NAME*}</a>',
        ],
        'playstation_network' => [
            'type' => 'codename',
            'icon' => 'icons/links/playstation_network',
            'section' => '',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="https://secure.eu.playstation.com/psn/{RAW*}/" rel="me">{NAME*}</a>',
        ],
        'xbox_live' => [
            'type' => 'codename',
            'icon' => 'icons/links/xbox_live',
            'section' => '',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="http://live.xbox.com/en-US/Profile?gamertag={RAW*}" rel="me">{NAME*}</a>',
        ],
        'steam' => [
            'type' => 'codename',
            'icon' => 'icons/links/steam',
            'section' => '',
            'tempcode' => '{NAME*}: {RAW*}',
        ],
        'sn_steemit' => [
            'type' => 'codename',
            'icon' => 'icons/links/steemit',
            'section' => '',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="https://steemit.com/@{RAW*}" rel="me">{NAME*}</a>',
        ],
        'utopian' => [
            'type' => 'codename',
            'icon' => 'icons/links/utopian',
            'section' => '',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="https://steemit.com/@{RAW*}" rel="me">{NAME*}</a>',
        ],
        'dtube' => [
            'type' => 'codename',
            'icon' => 'icons/links/dtube',
            'section' => '',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="https://d.tube/c/{RAW*}" rel="me">{NAME*}</a>',
        ],
        'im_line' => [
            'type' => 'codename',
            'icon' => 'icons/links/line',
            'section' => 'contact',
            'tempcode' => '{NAME*}: {RAW*}',
        ],
        'im_viber' => [
            'type' => 'codename',
            'icon' => 'icons/links/viber',
            'section' => 'contact',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="viber://add?number={RAW*}" rel="me">{NAME*}</a>',
        ],
        'sn_facebook' => [
            'type' => 'url',
            'icon' => 'icons/links/facebook',
            'section' => '',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="{RAW*}" rel="me">{NAME*}</a>',
        ],
        'amazon' => [
            'type' => 'url',
            'icon' => 'icons/links/amazon',
            'section' => '',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="{RAW*}" rel="me">{NAME*}</a>',
        ],
        'bandcamp' => [
            'type' => 'url',
            'icon' => 'icons/links/bandcamp',
            'section' => '',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="{RAW*}" rel="me">{NAME*}</a>',
        ],
        'dailymotion' => [
            'type' => 'url',
            'icon' => 'icons/links/dailymotion',
            'section' => '',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="{RAW*}" rel="me">{NAME*}</a>',
        ],
        'dropbox' => [
            'type' => 'url',
            'icon' => 'icons/links/dropbox',
            'section' => '',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="{RAW*}" rel="me">{NAME*}</a>',
        ],
        'flattr' => [
            'type' => 'url',
            'icon' => 'icons/links/flattr',
            'section' => '',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="{RAW*}" rel="me">{NAME*}</a>',
        ],
        'hacker_news' => [
            'type' => 'url',
            'icon' => 'icons/links/hacker_news',
            'section' => '',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="{RAW*}" rel="me">{NAME*}</a>',
        ],
        'sn_linkedin' => [
            'type' => 'url',
            'icon' => 'icons/links/linkedin',
            'section' => '',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="{RAW*}" rel="me">{NAME*}</a>',
        ],
        'patreon' => [
            'type' => 'url',
            'icon' => 'icons/links/patreon',
            'section' => '',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="{RAW*}" rel="me">{NAME*}</a>',
        ],
        'quora' => [
            'type' => 'url',
            'icon' => 'icons/links/quora',
            'section' => '',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="{RAW*}" rel="me">{NAME*}</a>',
        ],
        'sn_reddit' => [
            'type' => 'url',
            'icon' => 'icons/links/reddit',
            'section' => '',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="{RAW*}" rel="me">{NAME*}</a>',
        ],
        'slashdot' => [
            'type' => 'url',
            'icon' => 'icons/links/slashdot',
            'section' => '',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="{RAW*}" rel="me">{NAME*}</a>',
        ],
        'spotify' => [
            'type' => 'url',
            'icon' => 'icons/links/spotify',
            'section' => '',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="{RAW*}" rel="me">{NAME*}</a>',
        ],
        'stackexchange' => [
            'type' => 'url',
            'icon' => 'icons/links/stackexchange',
            'section' => '',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="{RAW*}" rel="me">{NAME*}</a>',
        ],
        'stack_overflow' => [
            'type' => 'url',
            'icon' => 'icons/links/stack_overflow',
            'section' => '',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="{RAW*}" rel="me">{NAME*}</a>',
        ],
        'vimeo' => [
            'type' => 'url',
            'icon' => 'icons/links/vimeo',
            'section' => '',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="{RAW*}" rel="me">{NAME*}</a>',
        ],
        'youtube' => [
            'type' => 'url',
            'icon' => 'icons/links/youtube',
            'section' => '',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="{RAW*}" rel="me">{NAME*}</a>',
        ],
        'sn_vkontakte' => [
            'type' => 'url',
            'icon' => 'icons/links/vkontakte',
            'section' => '',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="{RAW*}" rel="me">{NAME*}</a>',
        ],
        'sn_baidu_tieba' => [
            'type' => 'url',
            'icon' => 'icons/links/baidu_tieba',
            'section' => '',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="{RAW*}" rel="me">{NAME*}</a>',
        ],
        'sn_qzone' => [
            'type' => 'url',
            'icon' => 'icons/links/qzone',
            'section' => '',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="{RAW*}" rel="me">{NAME*}</a>',
        ],
        'bitcoin' => [
            'type' => 'url',
            'icon' => 'icons/links/bitcoin',
            'section' => '',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="{RAW*}" rel="me">{NAME*}</a>',
        ],
        'website' => [
            'type' => 'url',
            'icon' => 'icons/menu/home',
            'section' => '',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="{RAW*}" rel="me">{NAME*}</a>',
        ],
        'gender' => [
            'type' => 'short_text',
            'icon' => '',
            'section' => '',
            'tempcode' => '',
        ],
        'location' => [
            'type' => 'short_text',
            'icon' => '',
            'section' => '',
            'tempcode' => '',
        ],
        'occupation' => [
            'type' => 'short_text',
            'icon' => '',
            'section' => '',
            'tempcode' => '',
        ],
        'paypal' => [
            'type' => 'email',
            'icon' => 'icons/links/paypal',
            'section' => '',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="{RAW*}" rel="me">{NAME*}</a>',
        ],
        'sn_mastodon' => [
            'type' => 'email',
            'icon' => 'icons/links/mastodon',
            'section' => '',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="{$PREG_REPLACE*,(.*)@(.*),https://$2/@$1,{RAW}}" rel="me">{NAME*}</a>',
        ],
        'sn_diaspora' => [
            'type' => 'email',
            'icon' => 'icons/links/diaspora',
            'section' => '',
            'tempcode' => '<a title="{NAME*} {!LINK_NEW_WINDOW}" href="{$PREG_REPLACE*,(.*)@(.*),https://$2/@$1,{RAW}}" rel="me">{NAME*}</a>',
        ],
        'about' => [
            'type' => 'long_trans',
            'icon' => '',
            'section' => '',
            'tempcode' => '',
        ],
        'staff_notes' => [
            'type' => 'long_trans',
            'icon' => '',
            'section' => '',
            'tempcode' => '',
        ],
        'interests' => [
            'type' => 'long_trans',
            'icon' => '',
            'section' => '',
            'tempcode' => '',
        ],
    ];
}

/**
 * Make a Custom Profile Field from one of the predefined templates (this is often used by importers).
 * Also see the cpf_install source file.
 *
 * @param  ID_TEXT $type The identifier of the predefined Custom Profile Field
 * @return AUTO_LINK The ID of the new Custom Profile Field
 */
function cns_make_predefined_content_field(string $type) : int
{
    require_lang('cns');
    require_lang('cns_special_cpf');
    require_lang('fields');

    $details = cns_predefined_custom_field_details();
    $_type = $details[$type]['type'];
    $icon = $details[$type]['icon'];
    $section = $details[$type]['section'];
    $tempcode = $details[$type]['tempcode'];

    $public_view = 1;
    $owner_view = 1;
    $owner_set = 1;
    $required = 0;
    $show_in_posts = 0;
    $show_in_post_previews = 0;
    $include_in_main_search = 0;
    $allow_template_search = 0;

    if ($type == 'staff_notes') {
        $public_view = 0;
        $owner_view = 0;
        $owner_set = 0;
    }

    if ($type == 'interests' || $type == 'location') {
        $show_in_posts = 1;
        $show_in_post_previews = 1;
    }

    $title = do_lang('DEFAULT_CPF_' . $type . '_NAME');
    $description = do_lang('DEFAULT_CPF_' . $type . '_DESCRIPTION');

    return cns_make_custom_field($title, 0, $description, '', $public_view, $owner_view, $owner_set, 0, $_type, $required, $show_in_posts, $show_in_post_previews, null, '', 0, '', $include_in_main_search, $allow_template_search, $icon, $section, $tempcode, true);
}

/**
 * Rebuild custom profile field indices.
 *
 * @param  boolean $leave_existing Whether to leave existing indexes alone (may be useful as deleting then recreating indexes can be very slow)
 */
function rebuild_all_cpf_indices(bool $leave_existing = false)
{
    push_query_limiting(false);

    $fields = $GLOBALS['FORUM_DB']->query_select('f_custom_fields', ['*'], [], 'ORDER BY cf_include_in_main_search DESC,cf_required+cf_show_on_join_form DESC,cf_public_view+cf_owner_set DESC,cf_order DESC');

    if (!$leave_existing) {
        // Delete existing indexes
        foreach ($fields as $field) {
            $id = $field['id'];

            $GLOBALS['FORUM_DB']->delete_index_if_exists('f_member_custom_fields', 'field_' . strval($id)); // LEGACY
            $GLOBALS['FORUM_DB']->delete_index_if_exists('f_member_custom_fields', 'mcf' . strval($id));
            $GLOBALS['FORUM_DB']->delete_index_if_exists('f_member_custom_fields', '#mcf_ft_' . strval($id));
        }

        // Delete any stragglers (already deleted fields or inconsistent naming)
        $GLOBALS['FORUM_DB']->query_delete('db_meta_indices', ['i_table' => 'f_member_custom_fields']);
        if (strpos(get_db_type(), 'mysql') !== false) {
            $indexes = $GLOBALS['FORUM_DB']->query('SHOW INDEXES FROM ' . $GLOBALS['FORUM_DB']->get_table_prefix() . 'f_member_custom_fields WHERE ' . db_string_not_equal_to('Column_name', 'mf_member_id'));
            foreach ($indexes as $index) {
                $query = $GLOBALS['FORUM_DB']->driver->drop_index__sql($GLOBALS['FORUM_DB']->get_table_prefix() . 'f_member_custom_fields', $index['Key_name']);
                if ($query !== null) {
                    $GLOBALS['FORUM_DB']->query($query);
                }
            }
        }
    }

    // Rebuild indexes
    require_code('cns_members_action');
    foreach ($fields as $field) {
        $id = $field['id'];
        $type = $field['cf_type'];
        list($_type) = get_cpf_storage_for($type);

        $okay = build_cpf_indices($id, $field['cf_include_in_main_search'] == 1 || $field['cf_allow_template_search'] == 1, $type, $_type, true);
        if (!$okay) { // Limit was hit
            break;
        }
    }

    pop_query_limiting();
}

/**
 * Check if the date of birth field can be edited based on whether it is set and birthday points are given.
 *
 * @param  ?MEMBER $member_id The member being edited (null: we are creating a new member)
 * @return boolean If $member_id is null, whether the new member will be able to edit their DOB once set, and if $member_id is not null, whether the provided member can edit their DOB right now
 */
function cns_can_edit_birthday(?int $member_id) : bool
{
    $can_edit_birthday = true;
    $_birthday_points = get_option('points_birthday', true);
    $birthday_points = ($_birthday_points !== null) && ($_birthday_points != '') && (intval($_birthday_points) > 0);

    if ($member_id !== null) {
        $_dob_day = $GLOBALS['CNS_DRIVER']->get_member_row_field($member_id, 'm_dob_day');
        $_dob_month = $GLOBALS['CNS_DRIVER']->get_member_row_field($member_id, 'm_dob_month');
        $_dob_year = $GLOBALS['CNS_DRIVER']->get_member_row_field($member_id, 'm_dob_year');

        $can_edit_birthday = ((!$birthday_points) || // Birthday points is disabled
            (get_member() != $member_id) || // Editing member is not the same as the member being edited
            (!is_guest($member_id) && (($_dob_day === null) || ($_dob_month === null) || ($_dob_year === null)))); // Date of birth day, month, or year are not set, and we are not a guest.
    } else {
        $can_edit_birthday = (!$birthday_points);
    }

    return $can_edit_birthday;
}
