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

/*EXTRA FUNCTIONS: fsockopen|fileowner|filegroup|collator_.**/

/*
    global3.php contains further support functions, which are shared between the installer and the main installation (i.e. global.php and global2.php are not used by the installer, and the installer emulates these functions functionality via minikernel.php).
*/

/**
 * Standard code module initialisation function.
 *
 * @ignore
 */
function init__global3()
{
    if (!defined('DEFAULT_ZONE_PAGE_NAME')) {
        define('DEFAULT_ZONE_PAGE_NAME', 'home'); // We try and make this a constant. However it is in some .txt and .xml files
    }

    global $PAGE_NAME_CACHE;
    $PAGE_NAME_CACHE = null;

    // Heavily optimised string escaping data
    global $PHP_REP_FROM, $PHP_REP_TO, $PHP_REP_TO_TWICE;
    $PHP_REP_FROM = ['\\', "\n", '$', '"', "\0"];
    $PHP_REP_TO = ['\\\\', '\n', '\$', '\\"', '\0'];
    $PHP_REP_TO_TWICE = ['\\\\\\\\', '\\\\n', '\\\\\\$', '\\\\\"', '\\\\0'];

    global $BOT_MAP_CACHE, $BOT_TYPE_CACHE;
    $BOT_MAP_CACHE = null;
    $BOT_TYPE_CACHE = false;

    global $MSN_DB;
    $MSN_DB = null;

    // This is like null, but is a higher-precedence null that can also survive string layers (such as HTML forms). It should only be used when:
    //  - 'null' or '' or '-1' aren't appropriate (although '-1' is only appropriate when dealing with numbers held in strings, really).
    //  - OR, as the standard "ignore this field" indicator for query_update (so that "fractional edits" can happen without requiring a secondary API set or a messed up primary API)
    if (!defined('STRING_MAGIC_NULL')) {
        define('STRING_MAGIC_NULL', '!--:)abcUNLIKELY');
    }
    // This is similar, but for integers. As before, it should only be used when null and -1 aren't appropriate OR as the "ignore this field" indicator.
    if (!defined('INTEGER_MAGIC_NULL')) {
        define('INTEGER_MAGIC_NULL', 1634817353); // VERY unlikely to occur, but is both a 32bit unsigned and a 32 bit signed number
    }

    global $ZONE_DEFAULT_PAGES_CACHE;
    $ZONE_DEFAULT_PAGES_CACHE = [];

    global $IS_WIDE_CACHE, $IS_WIDE_HIGH_CACHE;
    $IS_WIDE_CACHE = null;
    $IS_WIDE_HIGH_CACHE = null;

    global $ADDON_INSTALLED_CACHE;
    $ADDON_INSTALLED_CACHE = [];

    global $OUTPUT_STATE_STACK;
    $OUTPUT_STATE_STACK = [];

    // Registry of output state globals
    global $OUTPUT_STATE_VARS;
    $OUTPUT_STATE_VARS = [
        'HTTP_STATUS_CODE',
        'METADATA',
        'ATTACHED_MESSAGES',
        'ATTACHED_MESSAGES_RAW',
        'LATE_ATTACHED_MESSAGES',
        'SEO_KEYWORDS',
        'SEO_DESCRIPTION',
        'BREADCRUMBS',
        'BREADCRUMB_SET_PARENTS',
        'DISPLAYED_TITLE',
        'SHORT_TITLE',
        'FORCE_SET_TITLE',
        'BREADCRUMB_SET_SELF',
        'FEED_URLS',
        'OUTPUT_STATE_STACK',
        'REFRESH_URL',
        'FORCE_META_REFRESH',
        'QUICK_REDIRECT',
        'EXTRA_HEAD',
        'EXTRA_FOOT',
        'HELPER_PANEL_TEXT',
        'HELPER_PANEL_TUTORIAL',
        'JAVASCRIPT',
        'JAVASCRIPTS',
        'JS_OUTPUT_STARTED',
        'CSSS',
        'CSS_OUTPUT_STARTED',
        'CYCLES',
        'TEMPCODE_SETGET',
        'COMCODE_PARSE_TITLE',
    ];
    _load_blank_output_state(false, false, true);

    global $MASS_IMPORT_HAPPENING;
    $MASS_IMPORT_HAPPENING = false;

    if (!defined('A_NA')) {
        // Notifications (defined here, as notification_poller may need them - yet we don't want to include all the notification dispatch code)
        // If you change this, be sure to update generate_notification_type_text in notifications.php
        define('A_NA', 0x0); // Not applicable          (0 in decimal)
        //
        define('A_INSTANT_EMAIL', 0x2);         // (2 in decimal)
        define('A_DAILY_EMAIL_DIGEST', 0x4);    // (4 in decimal)
        define('A_WEEKLY_EMAIL_DIGEST', 0x8);   // (8 in decimal)
        define('A_MONTHLY_EMAIL_DIGEST', 0x10); // (16 in decimal)
        define('A_INSTANT_SMS', 0x20);          // (32 in decimal)
        define('A_INSTANT_PT', 0x40);           // (64 in decimal)  Private topic
        define('A_WEB_NOTIFICATION', 0x80);     // (128 in decimal) Desktop notification if site is open, and always shows on notification dropdown
        // And...
        define('A__ALL', 0xFFFFFF);
        // And...
        define('A__STATISTICAL', -0x1); // This is magic, it will choose whatever the user probably wants, based on their existing settings
        define('A__CHOICE', -0x2); // Never stored in DB, used as a flag inside admin_notifications module
        define('A__INBUILT_DEFAULT', -0x4); // Never stored in DB, used as a flag inside admin_notifications module
    }

    global $ESCAPE_HTML_OUTPUT, $KNOWN_TRUE_HTML; // Used to track what is already escaped in kid-gloves modes
    $ESCAPE_HTML_OUTPUT = [];
    $KNOWN_TRUE_HTML = [];

    if (!defined('WYSIWYG_COMCODE__BUTTON')) {
        // Would normally put these in sources/comcode.php, but some of our templating references these constants
        define('WYSIWYG_COMCODE__BUTTON', 1);
        define('WYSIWYG_COMCODE__XML_BLOCK', 2);
        define('WYSIWYG_COMCODE__XML_BLOCK_ESCAPED', WYSIWYG_COMCODE__XML_BLOCK + 4);
        define('WYSIWYG_COMCODE__XML_BLOCK_ANTIESCAPED', WYSIWYG_COMCODE__XML_BLOCK + 8);
        define('WYSIWYG_COMCODE__XML_INLINE', 16);
        define('WYSIWYG_COMCODE__STANDOUT_BLOCK', WYSIWYG_COMCODE__XML_BLOCK + 32);
        define('WYSIWYG_COMCODE__STANDOUT_INLINE', WYSIWYG_COMCODE__XML_INLINE + 64);
        define('WYSIWYG_COMCODE__HTML', 128);
    }

    global $DOING_OUTPUT_PINGS;
    $DOING_OUTPUT_PINGS = false;

    global $DISABLE_SMART_DECACHING_TEMPORARILY;
    $DISABLE_SMART_DECACHING_TEMPORARILY = false;

    if (!defined('FILE_READ_LOCK')) {
        define('FILE_READ_LOCK', 1);
        define('FILE_READ_BOM', 2);
        define('FILE_READ_UNIXIFIED_TEXT', 4);
    }

    // Time limits...

    if (!defined('TIME_LIMIT_EXTEND__MODEST')) {
        define('TIME_LIMIT_EXTEND__MODEST', 30);
        define('TIME_LIMIT_EXTEND__SLUGGISH', 100);
        define('TIME_LIMIT_EXTEND__SLOW', 300);
        define('TIME_LIMIT_EXTEND__CRAWL', 1000);
    }

    global $ASCII_LCASE_MAP, $ASCII_UCASE_MAP;
    $ASCII_LCASE_MAP = ['a' => 'A', 'b' => 'B', 'c' => 'C', 'd' => 'D', 'e' => 'E', 'f' => 'F', 'g' => 'G', 'h' => 'H', 'i' => 'I', 'j' => 'J', 'k' => 'K', 'l' => 'L', 'm' => 'M', 'n' => 'N', 'o' => 'O', 'p' => 'P', 'q' => 'Q', 'r' => 'R', 's' => 'S', 't' => 'T', 'u' => 'U', 'v' => 'V', 'w' => 'W', 'x' => 'X', 'y' => 'Y', 'z' => 'Z'];
    $ASCII_UCASE_MAP = ['A' => 'a', 'B' => 'b', 'C' => 'c', 'D' => 'd', 'E' => 'e', 'F' => 'f', 'G' => 'g', 'H' => 'h', 'I' => 'i', 'J' => 'j', 'K' => 'k', 'L' => 'l', 'M' => 'm', 'N' => 'n', 'O' => 'o', 'P' => 'p', 'Q' => 'q', 'R' => 'r', 'S' => 's', 'T' => 't', 'U' => 'u', 'V' => 'v', 'W' => 'w', 'X' => 'x', 'Y' => 'y', 'Z' => 'z'];

    global $FORCE_IMMEDIATE_LOG_IT;
    $FORCE_IMMEDIATE_LOG_IT = [];
}

/**
 * Find the base URL for documentation.
 *
 * @return URLPATH The base URL for documentation
 */
function get_brand_base_url() : string
{
    $value = function_exists('get_value') ? get_value('rebrand_base_url') : null;
    if (empty($value)) {
        $value = 'https://composr.app';
    }
    return $value;
}

/**
 * Get a URL to a Composr tutorial.
 *
 * @param  ?ID_TEXT $tutorial Name of a tutorial (null: don't include the page part)
 * @return URLPATH URL to a tutorial
 */
function get_tutorial_url(?string $tutorial) : string
{
    $ret = get_brand_page_url(['page' => ($tutorial === null) ? 'abcdef' : $tutorial], 'docs' . strval(cms_version()));
    if ($tutorial === null) {
        $ret = str_replace('abcdef.htm', '', $ret);
    }
    return $ret;
}

/**
 * Get a URL to a composr.app page.
 *
 * @param  array $params URL map
 * @param  ID_TEXT $zone Zone
 * @return URLPATH URL to page
 */
function get_brand_page_url(array $params, string $zone) : string
{
    // Assumes brand site supports .htm URLs, which it should
    $base = get_brand_base_url() . (($zone == '') ? '' : '/') . $zone . '/';
    if (isset($params['page'])) {
        $base .= urlencode(str_replace('_', '-', $params['page']));
        if (isset($params['type'])) {
            $base .= '/' . urlencode(str_replace('_', '-', $params['type']));
            if (isset($params['id'])) {
                $base .= '/' . urlencode(str_replace('_', '-', $params['id'])) . '.htm';
                unset($params['id']);
            } else {
                $base .= '.htm';
            }
            unset($params['type']);
        }
        unset($params['page']);
    } else {
        $base .= '.htm';
    }

    $query_string = '';
    foreach ($params as $k => $v) {
        if ($query_string == '') {
            $query_string .= '?';
        } else {
            $query_string .= '&';
        }

        $query_string .= $k . '=' . cms_urlencode($v);
    }

    return $base . $query_string;
}

/**
 * Get the brand name.
 *
 * @return string The brand name
 */
function brand_name() : string
{
    $value = function_exists('get_value') ? get_value('rebrand_name') : null;
    if ($value === null) {
        $value = 'Composr';
    }
    return $value;
}

/**
 * Get the file extension of the specified file. It returns without a dot. File extensions are considered never to themselves contain a dot.
 *
 * @param  ?string $name The filename (null: unknown)
 * @param  ?string $mime_type The mime-type (null: unknown)
 * @return string The filename extension (no dot)
 */
function get_file_extension(?string $name, ?string $mime_type = null) : string
{
    if ($mime_type !== null) {
        require_code('mime_types');
        $ext = get_ext_from_mime_type($mime_type);
        if ($ext !== null) {
            return $ext;
        }
    }

    if ($name === null) {
        return '';
    }

    $dot_pos = strrpos($name, '.');
    if ($dot_pos === false) {
        return '';
    }
    return cms_strtolower_ascii(substr($name, $dot_pos + 1));
}

/**
 * Find whether we are using natural file-owner-based access for PHP.
 * Such access avoids us having to be messing with AFMs, world-writability, etc.
 * Note that this says nothing about what user the web server is running as, just what PHP is running as (those are not always the same).
 * Always will return false on Windows due to missing Posix - but there's no such thing as chmodding files for non-owners on Windows either.
 *
 * @return boolean Whether we have this
 */
function is_suexec_like() : bool
{
    if (running_script('webdav')) {
        return true; // Has to assume so, as cannot intercede
    }

    if (GOOGLE_APPENGINE) {
        return false;
    }

    static $answer = null;
    if ($answer === null) {
        if (is_cli()) {
            $opts = getopt('', ['is_suexec_like::']);
            if (array_key_exists('is_suexec_like', $opts)) {
                $answer = true;
            } else {
                $answer = false; // As we cannot know
            }
        } else {
            $answer = (
                (php_function_allowed('posix_getuid')) &&
                (!isset($_SERVER['HTTP_X_MOSSO_DT'])) &&
                (is_integer(@posix_getuid())) &&
                (posix_getuid() == website_file_owner())
            )
            ||
            (cms_is_writable(get_file_base() . (running_script('install') ? '/install.php' : '/sources/global.php')));
        }
    }
    return $answer;
}

/**
 * Ensure that the specified file/folder is writeable for the FTP user (so that it can be deleted by the system), and should be called whenever a file is uploaded/created, or a folder is made. We call this function assuming we are giving world permissions.
 *
 * @param  PATH $path The full pathname to the file/directory
 * @param  ?integer $perms The permissions to make (not the permissions are reduced if the function finds that the file is owned by the web user [doesn't need world permissions then]) (null: default for file/dir)
 */
function fix_permissions(string $path, ?int $perms = null)
{
    if ($perms === null) {
        $perms = is_dir($path) ? 0777 : 0666;
    }

    // If the file user is different to the web user, we need to make it world writeable
    if (!is_suexec_like()) {
        if ($perms == 0600) {
            @chmod($path, 0666);
        } else {
            @chmod($path, $perms);
        }
    } else { // Otherwise we do not
        if ($perms == 0666) {
            @chmod($path, 0644);
        } elseif ($perms == 0777) {
            @chmod($path, 0755);
        } else {
            @chmod($path, $perms);
        }
    }

    global $_CREATED_FILES; // From ocProducts PHP version, for development testing
    if (isset($_CREATED_FILES)) {
        foreach ($_CREATED_FILES as $i => $x) {
            if ($x == $path) {
                unset($_CREATED_FILES[$i]);
            }
        }
    }
}

/**
 * Reads entire file into an array.
 * Supports locking and character set conversion (using BOMs).
 *
 * @param  PATH $path File path
 * @param  ?string $default_charset The default character set if none is specified (null: website character set)
 * @return ~array The array (each line being an entry in the array, and newlines still attached) (false: error)
 */
function cms_file_safe(string $path, ?string $default_charset = null)
{
    $c = cms_file_get_contents_safe($path, FILE_READ_LOCK | FILE_READ_BOM | FILE_READ_UNIXIFIED_TEXT, $default_charset);
    if ($c === false) {
        return false;
    }

    $lines = explode("\n", $c);
    return $lines;
}

/**
 * Get the contents of a file, with locking support.
 *
 * @param  PATH $path File path
 * @param  integer $flags FILE_READ_* flags
 * @param  ?string $default_charset The default character set to assume if none is specified in the file (null: website character set)
 * @param  ?integer $max_bytes Maximum number of bytes to read (null: read all bytes)
 * @return ~string File contents (false: error)
 */
function cms_file_get_contents_safe(string $path, int $flags = 0, ?string $default_charset = null, ?int $max_bytes = null)
{
    $locking = ($flags & FILE_READ_LOCK) != 0;
    $handle_file_bom = ($flags & FILE_READ_BOM) != 0;
    $unixify_line_format = ($flags & FILE_READ_UNIXIFIED_TEXT) != 0;

    $tmp = @fopen($path, 'rb');
    if ($tmp === false) {
        return false;
    }
    if ($locking) {
        flock($tmp, LOCK_SH);
    }
    $contents = stream_get_contents($tmp, ($max_bytes === null) ? -1 : $max_bytes);
    if ($locking) {
        flock($tmp, LOCK_UN);
    }
    fclose($tmp);

    if ($handle_file_bom) {
        $contents = handle_string_bom($contents, $default_charset);
    }
    if ($unixify_line_format) {
        $contents = unixify_line_format($contents);
    }

    return $contents;
}

/**
 * Find available byte-order-marks we support.
 *
 * @return array A map between character sets and BOM byte strings
 */
function _get_boms() : array
{
    return [
        'utf-32' => hex2bin('fffe0000'), // LE, which is de facto standard that convert_to_internal_encoding assumes
        'utf-32BE' => hex2bin('0000feff'),
        'utf-16' => hex2bin('fffe'), // LE, which (...)
        'utf-16BE' => hex2bin('feff'),
        'utf-8' => hex2bin('efbbbf') , // No LE vs BE distinction for utf-8
        'GB-18030' => hex2bin('84319533'),
    ];
}

/**
 * Detect a BOM (Unicode byte-order-mark) from a string, and strip it. Return the altered string.
 *
 * @param  string $contents Input string
 * @param  ?string $default_charset The default character set to assume if none is specified in the input string (null: website character set)
 * @return string Altered string
 */
function handle_string_bom(string $contents, ?string $default_charset = null) : string
{
    require_code('character_sets');

    list($file_charset, $bom) = detect_string_bom($contents);
    if ($file_charset !== null) {
        $contents = substr($contents, strlen($bom));
    } else {
        $file_charset = $default_charset;
    }
    $contents = convert_to_internal_encoding($contents, $file_charset);

    return $contents;
}

/**
 * Detect a BOM (Unicode byte-order-mark) from a string, and find the associated character set.
 *
 * @param  string $contents Input string
 * @return array A pair: The character set (null is unknown), The BOM (null is unknown)
 */
function detect_string_bom(string $contents) : array
{
    $file_charset = null;
    $bom_found = null;

    $boms = _get_boms();

    $max_bom_len = 0;
    foreach ($boms as $bom) {
        if (strlen($bom) > $max_bom_len) {
            $max_bom_len = strlen($bom);
        }
    }
    $magic_data = substr($contents, 0, $max_bom_len);

    foreach ($boms as $charset => $bom) {
        if (substr($magic_data, 0, strlen($bom)) == $bom) {
            $file_charset = $charset;
            $bom_found = $bom;
            break;
        }
    }

    return [$file_charset, $bom_found];
}

/**
 * Return the contents of the URL by downloading it over HTTP. If a byte limit is given, it will only download that many bytes. It outputs warnings, returning null, on error.
 *
 * @param  URLPATH $url The URL to download
 * @param  array $options Map of options (see the properties of the HttpDownloader class for what you may set)
 * @return ?string The data downloaded (null: error)
 */
function http_get_contents(string $url, array $options = []) : ?string
{
    cms_profile_start_for('http_get_contents');
    $ob = cms_http_request($url, $options);
    $ret = $ob->data;
    cms_profile_end_for('http_get_contents', $url);
    return $ret;
}

/**
 * Return the file in the URL by downloading it over HTTP. If a byte limit is given, it will only download that many bytes. It outputs warnings, returning null, on error.
 *
 * @param  URLPATH $url The URL to download
 * @param  array $options Map of options (see the properties of the HttpDownloader class for what you may set)
 * @return object HttpDownloader object, which can be checked for return data
 */
function cms_http_request(string $url, array $options = []) : object
{
    require_code('http');
    return _cms_http_request($url, $options);
}

/**
 * Make a low-level fsockopen request to an API.
 * Ideally, you should use a secure endpoint and encrypt the data to be sent using encrypt_message().
 *
 * @param  string $payload The payload to send (blank: make a GET request)
 * @param  URLPATH $url The URL to call including the path and port
 * @param  ?integer $error_code The error code returned (passed by reference) (null: No error)
 * @param  string $error_message The error message returned (passed by reference) (blank: No error)
 * @param  float $timeout The timeout in seconds
 * @return ?string response from the fsock (null: error)
 */
function cms_fsock_request(string $payload, string $url, ?int &$error_code = null, ?string &$error_message = '', float $timeout = 6.0) : ?string
{
    cms_profile_start_for('cms_fsock_request');

    require_code('encryption');

    $hostname = cms_parse_url_safe($url, PHP_URL_HOST);
    $fsock_hostname = $hostname;
    $port = cms_parse_url_safe($url, PHP_URL_PORT);
    if (is_encryption_available() && cms_parse_url_safe($url, PHP_URL_SCHEME) == 'https') {
        $fsock_hostname = 'tls://' . $hostname;
    }
    $path = cms_parse_url_safe($url, PHP_URL_PATH);
    $query = cms_parse_url_safe($url, PHP_URL_QUERY);
    if ($query != '') {
        $path .= '?' . $query;
    }

    $fsock = fsockopen($fsock_hostname, $port, $error_code, $error_message, $timeout);
    if ($fsock === false) {
        cms_profile_end_for('cms_fsock_request', $hostname . ':' . strval($port));
        return null;
    }

    // Construct the HTTP request
    if ($payload != '') {
        $request = 'POST ' . $path . ' HTTP/1.1' . "\r\n";
        $request .= 'Host: ' . $hostname . "\r\n";
        $request .= 'Content-Type: application/json' . "\r\n";
        $request .= 'Content-Length: ' . strval(strlen($payload)) . "\r\n";
        $request .= 'Connection: close' . "\r\n\r\n";
        $request .= $payload . "\r\n\r\n";
    } else {
        $request = 'GET ' . $path . ' HTTP/1.1' . "\r\n";
        $request .= 'Host: ' . $hostname . "\r\n";
        $request .= 'Connection: close' . "\r\n\r\n";
    }

    // Send the request
    $fwrite = fwrite($fsock, $request);
    if ($fwrite === false) {
        cms_profile_end_for('cms_fsock_request', $hostname . ':' . strval($port));
        return null;
    }

    // Read the response
    $response = '';
    while (!feof($fsock)) {
        $_response = fgets($fsock, 128);
        if ($_response === false) {
            cms_profile_end_for('cms_fsock_request', $hostname . ':' . strval($port));
            return null;
        }
        $response .= $_response;
    }

    fclose($fsock);

    cms_profile_end_for('cms_fsock_request', $hostname . ':' . strval($port));
    return $response;
}

/**
 * Find whether a file/directory is writeable. This function is designed to get past that the PHP is_writable function does not work properly on Windows.
 *
 * @param  PATH $path The file path
 * @return boolean Whether the file is writeable
 */
function cms_is_writable(string $path) : bool
{
    if (cms_strtoupper_ascii(substr(PHP_OS, 0, 3)) != 'WIN') {
        return is_writable($path);
    }

    // Windows is more tricky...

    if (!file_exists($path)) {
        return false;
    }

    if (is_dir($path)) {
        /*if (false) { // ideal, but too dangerous as sometimes you can write files but not delete again
            $test = @fopen($path . '/cms.delete.me', 'wb');
            if ($test !== false) {
                fclose($test);
                unlink($path . '/cms.delete.me');
                return true;
            }
            return false;
        }*/

        return is_writable($path); // imperfect unfortunately; but unlikely to cause a problem
    }

    $test = @fopen($path, 'cb');
    if ($test !== false) {
        fclose($test);
        return true;
    }
    return false;
}

/**
 * Discern the cause of a file-write error, and show an appropriate error message.
 *
 * @param  PATH $path File path that could not be written (full path, not relative)
 */
function intelligent_write_error(string $path)
{
    require_code('files2');
    _intelligent_write_error($path);
}

/**
 * Discern the cause of a file-write error, and return an appropriate error message.
 *
 * @param  PATH $path File path that could not be written
 * @param  boolean $force_hardcoded Whether to force a hard-coded error message, useful if we have not finished bootstrapping
 * @return mixed Message (string or Tempcode)
 */
function intelligent_write_error_inline(string $path, bool $force_hardcoded = false)
{
    require_code('files2');
    return _intelligent_write_error_inline($path, $force_hardcoded);
}

/**
 * Load a fresh output state.
 *
 * @sets_output_state
 *
 * @param  boolean $just_tempcode Whether to only restore the Tempcode execution part of the state
 * @param  boolean $true_blank Whether to go for a completely blank state (no defaults!), not just a default fresh state
 * @param  boolean $initial Whether this is an initial state, and we can leave anything that is already set as set (handled on case-by-case basis)
 *
 * @ignore
 */
function _load_blank_output_state(bool $just_tempcode = false, bool $true_blank = false, bool $initial = false)
{
    /*
    Now lots of stuff all relating to output state (unless commented, these GLOBALs should not be written to directly, we have API calls for it)
    */

    if (!$just_tempcode) {
        global $HTTP_STATUS_CODE;
        /** Record of the HTTP status code being set.
         *
         * @sets_output_state
         *
         * @global string $HTTP_STATUS_CODE
         */
        $HTTP_STATUS_CODE = 200;

        global $METADATA;
        $METADATA = [];

        global $ATTACHED_MESSAGES, $ATTACHED_MESSAGES_RAW, $LATE_ATTACHED_MESSAGES;
        if (!$initial || !isset($ATTACHED_MESSAGES)) {
            $ATTACHED_MESSAGES = null;
        }
        if (!$initial || !isset($ATTACHED_MESSAGES_RAW)) {
            /** Raw data of attached messages.
             *
             * @sets_output_state
             *
             * @global ?array $ATTACHED_MESSAGES_RAW
             */
            $ATTACHED_MESSAGES_RAW = [];
        }
        $LATE_ATTACHED_MESSAGES = null;

        global $SEO_KEYWORDS, $SEO_DESCRIPTION, $SHORT_TITLE;
        $SEO_KEYWORDS = null;
        $SEO_DESCRIPTION = null;
        /** Shortened title to use only within header text and thus <title> tag (if not set, $DISPLAYED_TITLE will be used).
         *
         * @sets_output_state
         *
         * @global ?string $SHORT_TITLE
         */
        $SHORT_TITLE = null;

        global $BREADCRUMBS, $BREADCRUMB_SET_PARENTS, $DISPLAYED_TITLE, $FORCE_SET_TITLE, $BREADCRUMB_SET_SELF;
        $BREADCRUMBS = null;
        $BREADCRUMB_SET_PARENTS = [];
        /** The screen title that was set (i.e. <h1>).
         *
         * @sets_output_state
         *
         * @global ?string $DISPLAYED_TITLE
         */
        $DISPLAYED_TITLE = null;
        $FORCE_SET_TITLE = false;
        $BREADCRUMB_SET_SELF = null;

        global $FEED_URLS;
        $FEED_URLS = [];

        global $REFRESH_URL, $FORCE_META_REFRESH, $QUICK_REDIRECT;
        $REFRESH_URL[0] = '';
        $REFRESH_URL[1] = 0.0;
        $FORCE_META_REFRESH = false;
        $QUICK_REDIRECT = false;

        global $EXTRA_HEAD, $EXTRA_FOOT;
        $EXTRA_HEAD = null;
        $EXTRA_FOOT = null;

        global $HELPER_PANEL_TEXT, $HELPER_PANEL_TUTORIAL;
        $HELPER_PANEL_TEXT = '';
        $HELPER_PANEL_TUTORIAL = '';

        // Register basic CSS and JavaScript requirements
        global $JAVASCRIPT, $JAVASCRIPTS, $CSSS, $JAVASCRIPTS_DEFAULT;
        $JAVASCRIPT = null;
        /** List of required JavaScript files.
         *
         * @sets_output_state
         *
         * @global ?array $JAVASCRIPTS
         */
        $JAVASCRIPTS = $true_blank ? [] : $JAVASCRIPTS_DEFAULT;
        /** List of required CSS files.
         *
         * @sets_output_state
         *
         * @global ?array $CSSS
         */
        $CSSS = $true_blank ? [] : ['no_cache' => true, 'global' => true];
    }

    global $CYCLES, $TEMPCODE_SETGET;
    /** Stores Tempcode CYCLE values during execution.
     *
     * @sets_output_state
     *
     * @global array $CYCLE
     */
    $CYCLES = [];
    /** Stores Tempcode variable values during execution.
     *
     * @sets_output_state
     *
     * @global array $TEMPCODE_SETGET
     */
    $TEMPCODE_SETGET = [];
}

/**
 * Push the output state on the stack and create a fresh one.
 *
 * @sets_output_state
 *
 * @param  boolean $just_tempcode Whether to only restore the Tempcode execution part of the state
 * @param  boolean $true_blank Whether to go for a completely blank state (no defaults!), not just a default fresh state
 */
function push_output_state(bool $just_tempcode = false, bool $true_blank = false)
{
    global $OUTPUT_STATE_STACK, $OUTPUT_STATE_VARS;
    $current_state = [];
    foreach ($OUTPUT_STATE_VARS as $var) {
        $current_state[$var] = isset($GLOBALS[$var]) ? $GLOBALS[$var] : null;
    }
    array_push($OUTPUT_STATE_STACK, $current_state);
    _load_blank_output_state($just_tempcode, $true_blank);
}

/**
 * Restore the last output state on the stack, or a fresh one if none was pushed.
 *
 * @sets_output_state
 *
 * @param  boolean $just_tempcode Whether to only restore the Tempcode execution part of the state
 * @param  boolean $merge_current Whether to merge the current output state in (or take precedence when merging isn't applicable)
 * @param  ?array $keep Settings to keep (not replace) / merge if possible (null: merge all)
 */
function restore_output_state(bool $just_tempcode = false, bool $merge_current = false, ?array $keep = null)
{
    global $OUTPUT_STATE_STACK;

    $mergeable_arrays = ['METADATA' => true, 'JAVASCRIPTS' => true, 'CSSS' => true, 'TEMPCODE_SETGET' => true, 'CYCLES' => true, 'ATTACHED_MESSAGES_RAW' => true];
    $mergeable_tempcode = ['EXTRA_HEAD' => true, 'EXTRA_FOOT' => true, 'JAVASCRIPT' => true, 'ATTACHED_MESSAGES' => true, 'LATE_ATTACHED_MESSAGES' => true];

    $old_state = array_pop($OUTPUT_STATE_STACK);
    if ($old_state === null) {
        _load_blank_output_state($just_tempcode);
    } else {
        foreach ($old_state as $var => $val) {
            if ((!$just_tempcode) || ($var == 'CYCLES') || ($var == 'TEMPCODE_SETGET')) {
                $merge_array = (($merge_current) && (is_array($val)) && (isset($mergeable_arrays[$var])));
                $merge_tempcode = (($merge_current) && (isset($val->codename/*faster than is_object*/, $mergeable_tempcode[$var])));
                $mergeable = $merge_array || $merge_tempcode;
                if (($keep === null) || (!in_array($var, $keep)) || ($mergeable)) {
                    if ($merge_array) {
                        if ($GLOBALS[$var] === null) {
                            $GLOBALS[$var] = [];
                        }
                        $GLOBALS[$var] = array_merge($val, $GLOBALS[$var]);
                    } elseif ($merge_tempcode) {
                        if ($GLOBALS[$var] === null) {
                            $GLOBALS[$var] = new Tempcode();
                        }
                        if ($val !== null) {
                            $GLOBALS[$var]->attach($val);
                        }
                    } elseif ((!$merge_current) || (!isset($GLOBALS[$var])) || (cms_empty_safe($GLOBALS[$var])) || ($var == 'REFRESH_URL') || (($var == 'HTTP_STATUS_CODE') && ($GLOBALS['HTTP_STATUS_CODE'] == 200))) {
                        $GLOBALS[$var] = $val;
                    }
                }
            }
        }
    }
}

/**
 * Turn the Tempcode lump into a standalone page.
 *
 * @param  Tempcode $middle The Tempcode to put into a nice frame
 * @param  ?mixed $message 'Additional' message (null: none)
 * @param  string $type The type of special message
 * @set inform warn ""
 * @param  boolean $include_header_and_footer Whether to include the header/footer/panels
 * @param  boolean $show_border Whether to include a full screen rendering layout (will be overridable by 'show_border' GET parameter if present or if main page view)
 * @return Tempcode Standalone page
 */
function globalise(object $middle, $message = null, string $type = '', bool $include_header_and_footer = false, bool $show_border = false) : object
{
    if (!$include_header_and_footer) { // FUDGE
        $old = null;
        if (isset($_GET['wide_high'])) {
            $old = $_GET['wide_high'];
        }
        $_GET['wide_high'] = '1';
    }

    require_code('site');
    if ($message !== null) {
        attach_message($message, $type);
    }

    $show_border = (get_param_integer('show_border', $show_border ? 1 : 0) == 1);
    if (!$show_border && !running_script('index')) {
        $global = do_template('STANDALONE_HTML_WRAP', [
            '_GUID' => 'fe818a6fb0870f0b211e8e52adb23f26',
            'TITLE' => ($GLOBALS['DISPLAYED_TITLE'] === null) ? do_lang_tempcode('NA') : $GLOBALS['DISPLAYED_TITLE'],
            'FRAME' => running_script('iframe'),
            'TARGET' => '_self',
            'CONTENT' => $middle,
        ]);
        $global->handle_symbol_preprocessing();
        return $global;
    }

    global $TEMPCODE_CURRENT_PAGE_OUTPUTTING;

    global $DOING_OUTPUT_PINGS;
    if (headers_sent() && !$DOING_OUTPUT_PINGS) {
        $global = do_template('STANDALONE_HTML_WRAP', [
            '_GUID' => 'd579b62182a0f815e0ead1daa5904793',
            'TITLE' => ($GLOBALS['DISPLAYED_TITLE'] === null) ? do_lang_tempcode('NA') : $GLOBALS['DISPLAYED_TITLE'],
            'FRAME' => false,
            'TARGET' => '_self',
            'CONTENT' => $middle,
        ]);
    } else {
        $global = do_template('GLOBAL_HTML_WRAP', [
            '_GUID' => '592faa2c0e8bf2dc3492de2c11ca7131',
            'MIDDLE' => $middle,
        ]);
    }
    $global->handle_symbol_preprocessing();

    if ((!$include_header_and_footer) && ($old !== null)) {
        $_GET['wide_high'] = $old;
    }

    return $global;
}

/**
 * Attach some HTML to the screen footer.
 *
 * @sets_output_state
 *
 * @param  mixed $data HTML to attach, provided in HTML format (string or Tempcode)
 */
function attach_to_screen_footer($data)
{
    global $EXTRA_FOOT;
    if ($EXTRA_FOOT === null) {
        $EXTRA_FOOT = new Tempcode();
    }
    $EXTRA_FOOT->attach($data);
}

/**
 * Add some metadata for the request.
 * It is taken by:
 * a) Getting directly from the passed $metadata.
 * b) Looking up the CMA hook for the given $content_type, and using that to dereference properties from $row.
 *
 * @sets_output_state
 *
 * @param  array $metadata Extra metadata
 * @param  ?array $row Content row to automatically grab data from, if we also have $content_type (null: unknown)
 * @param  ?ID_TEXT $content_type Content type (null: unknown)
 * @param  ?ID_TEXT $content_id Content ID, used for some cases of deeper probing (null: unknown)
 */
function set_extra_request_metadata(array $metadata, ?array $row = null, ?string $content_type = null, ?string $content_id = null)
{
    // Add in specific data passed...

    // Pre-validation of stuff that may not be acceptable
    foreach ($metadata as $key => $val) {
        if ($val !== null) {
            $val = cms_trim($val);
            if ($val == '') {
                unset($metadata[$key]);
            } else {
                $metadata[$key] = $val;
            }
        }
    }
    if (isset($metadata['image'])) {
        if (!is_valid_opengraph_image($metadata['image'])) {
            unset($metadata['image']); // Cannot use it
        }
    }

    global $METADATA;
    $METADATA += $metadata; // First-set gets precedence. E.g. picture custom fields will only set 'image' if no valid image was found yet

    // Load up CMA hook...

    if ($content_type !== null) {
        require_code('content');
        $cma_ob = get_content_object($content_type);
        if ($cma_ob !== null) {
            $cma_info = $cma_ob->info();
            if ($cma_ob === null) {
                $content_type = null;
            }
        } else {
            $content_type = null;
        }
    }

    if (($row !== null) && ($content_type !== null)) {
        // Add in generic data...

        $cma_mappings = [
            'created' => 'add_time_field',
            'modified' => 'edit_time_field',
            'creator' => isset($cma_info['author_field']) ? 'author_field' : 'submitter_field',
            'publisher' => 'submitter_field',
            'views' => 'views_field',
            'validated' => 'validated_field',
        ];

        foreach ($cma_mappings as $meta_type => $cma_field) {
            if (!isset($METADATA[$meta_type])) {
                if (isset($cma_info[$cma_field]) && isset($row[$cma_info[$cma_field]])) {
                    switch ($meta_type) {
                        case 'created':
                        case 'modified':
                            $val_raw = strval($row[$cma_info[$cma_field]]);
                            $val = date('Y-m-d', $row[$cma_info[$cma_field]]);
                            break;

                        case 'creator':
                        case 'publisher':
                            if ($cma_field == 'author_field') {
                                $val_raw = $row[$cma_info[$cma_field]];
                                $val = $val_raw;
                            } else {
                                $val_raw = strval($row[$cma_info[$cma_field]]);
                                $val = $GLOBALS['FORUM_DRIVER']->get_username($row[$cma_info[$cma_field]]);
                            }
                            break;

                        case 'views':
                            $val_raw = strval($row[$cma_info[$cma_field]]);
                            $val = $val_raw;
                            break;

                        case 'validated':
                            $val_raw = strval($row[$cma_info[$cma_field]]);
                            $val = $val_raw;
                            break;

                        default:
                            $val_raw = $row[$cma_info[$cma_field]];
                            $val = $val_raw;
                            break;
                    }

                    if ($val !== null) {
                        $METADATA[$meta_type] = $val;
                        $METADATA[$meta_type . '_RAW'] = $val_raw;
                    }
                }
            }
        }

        // Add in fields that are a bit more difficult...

        if (!isset($METADATA['title'])) {
            $val_raw = $cma_ob->get_title($row);

            if ($content_type === 'comcode_page') {
                // FUDGE
                if ($content_id === ':' . DEFAULT_ZONE_PAGE_NAME) {
                    $val_raw = get_site_name();
                } else {
                    $val_raw = titleify($val_raw);
                }
            }

            $METADATA['title'] = $val_raw;
        }

        if (!isset($METADATA['description'])) {
            $METADATA['description'] = $cma_ob->get_description($row);
        }

        if (!isset($METADATA['type'])) {
            $METADATA['type'] = $cma_ob->get_content_type_universal_label($row);
        }

        if (!isset($METADATA['image'])) {
            $image_url = $cma_ob->get_image_url($row, (($content_type == 'comcode_page') && ($content_id === ':' . DEFAULT_ZONE_PAGE_NAME)) ? IMAGE_URL_FALLBACK_NONE : IMAGE_URL_FALLBACK_SOFT);

            if (($image_url != '') && (is_valid_opengraph_image($image_url))) {
                $METADATA['image'] = $image_url;
            }
        }

        if ((!isset($METADATA['video'])) && (isset($cma_info['video_generator']))) {
            list($METADATA['video'], $METADATA['video:width'], $METADATA['video:height'], $METADATA['video:type']) = call_user_func($cma_info['video_generator'], $row);
        }

        // Add all $cma_info
        $METADATA += $cma_info;
        unset($METADATA['db']);
        $METADATA['content_type_label_trans'] = $cma_ob->get_content_type_label($row);
    }

    // And let's throw in some more useful stuff...

    if ($content_type !== null) {
        $METADATA['content_type'] = $content_type;
    }

    if ($content_id !== null) {
        $METADATA['content_id'] = $content_id;
    }
}

/**
 * Find if an image is an Open Graph image.
 *
 * @param  URLPATH $url The URL
 * @return boolean Whether it is
 */
function is_valid_opengraph_image(string $url) : bool
{
    $ext = get_file_extension($url);

    require_code('images');
    if (is_image($url, IMAGE_CRITERIA_OPENGRAPH)) {
        $test = cms_getimagesize_url($url, true/*We won't even bother with non-local files, too much computational overhead*/);
        if ($test !== false) {
            list($width, $height) = $test;
            if (($width >= 200) && ($height >= 200)) {
                return true;
            }
        }
    }

    return false;
}

/**
 * Set the HTTP status code for the request.
 *
 * @sets_output_state
 *
 * @param  integer $code The HTTP status code
 */
function set_http_status_code(int $code)
{
    global $HTTP_STATUS_CODE;
    $HTTP_STATUS_CODE = $code; // So we can keep track

    if ((!headers_sent()) && (function_exists('browser_matches')) && (!browser_matches('ie')) && (strpos($_SERVER['SERVER_SOFTWARE'], 'IIS') === false)) {
        http_response_code($code);
    }
}

/**
 * Search for a template.
 *
 * @param  ID_TEXT $codename The codename of the template being loaded
 * @param  ID_TEXT $theme The theme to use
 * @param  string $suffix File type suffix of template file (e.g. .tpl)
 * @set .tpl .js .xml .txt .css
 * @param  string $directory Subdirectory type to look in
 * @set templates javascript xml text css
 * @param  boolean $non_custom_only Whether to only search in the default templates
 * @param  boolean $fallback_other_themes Allow fallback to other themes, in case it is defined only in a specific theme we would not normally look in
 * @return ?array List of parameters needed for the _do_template function to be able to load the template (null: could not find the template)
 */
function find_template_place(string $codename, string $theme, string $suffix, string $directory, bool $non_custom_only = false, bool $fallback_other_themes = true) : ?array
{
    global $FILE_ARRAY, $CURRENT_SHARE_USER;

    static $tp_cache = [];
    $sz = serialize([$codename, $theme, $suffix, $directory, $non_custom_only, $fallback_other_themes]);
    if (isset($tp_cache[$sz])) {
        return $tp_cache[$sz];
    }

    $prefix_default = get_file_base() . '/themes/';
    $prefix = ($theme == 'default' || $theme == 'admin') ? $prefix_default : (get_custom_file_base() . '/themes/');

    if (!isset($FILE_ARRAY)) {
        if ((is_file($prefix . $theme . '/' . $directory . '_custom/' . $codename . $suffix)) && (!in_safe_mode()) && (!$non_custom_only)) {
            $place = [$theme, '/' . $directory . '_custom/', $suffix];
        } elseif (is_file($prefix . $theme . '/' . $directory . '/' . $codename . $suffix)) {
            $place = [$theme, '/' . $directory . '/', $suffix];
        } elseif (($CURRENT_SHARE_USER !== null) && ($theme !== 'default') && (is_file(get_file_base() . '/themes/' . $theme . '/' . $directory . '_custom/' . $codename . $suffix)) && (!$non_custom_only)) {
            $place = [$theme, '/' . $directory . '_custom/', $suffix];
        } elseif (($CURRENT_SHARE_USER !== null) && ($theme !== 'default') && (is_file(get_file_base() . '/themes/' . $theme . '/' . $directory . '/' . $codename . $suffix))) {
            $place = [$theme, '/' . $directory . '/', $suffix];
        } elseif (($CURRENT_SHARE_USER !== null) && (is_file(get_custom_file_base() . '/themes/default/' . $directory . '_custom/' . $codename . $suffix)) && (!$non_custom_only)) {
            $place = ['default', '/' . $directory . '_custom/', $suffix];
        } elseif (($CURRENT_SHARE_USER !== null) && (is_file(get_custom_file_base() . '/themes/default/' . $directory . '/' . $codename . $suffix))) {
            $place = ['default', '/' . $directory . '/', $suffix];
        } elseif ((is_file($prefix_default . 'default' . '/' . $directory . '_custom/' . $codename . $suffix)) && (!in_safe_mode()) && (!$non_custom_only)) {
            $place = ['default', '/' . $directory . '_custom/', $suffix];
        } elseif (is_file($prefix_default . 'default' . '/' . $directory . '/' . $codename . $suffix)) {
            $place = ['default', '/' . $directory . '/', $suffix];
        } else {
            $place = null;
        }

        if (($place === null) && (!$non_custom_only) && ($fallback_other_themes)) { // Get desperate, search in themes other than current and default
            $dh = opendir(get_file_base() . '/themes');
            while (($possible_theme = readdir($dh))) {
                if (($possible_theme[0] !== '.') && ($possible_theme !== 'default') && ($possible_theme !== $theme) && ($possible_theme !== 'map.ini') && ($possible_theme !== 'index.html')) {
                    $full_path = get_custom_file_base() . '/themes/' . $possible_theme . '/' . $directory . '_custom/' . $codename . $suffix;
                    if (is_file($full_path)) {
                        $place = [$possible_theme, '/' . $directory . '_custom/', $suffix];
                        break;
                    }
                }
            }
            closedir($dh);
        }
    } else {
        $place = ['default', '/' . $directory . '/', $suffix];
    }

    $tp_cache[$sz] = $place;
    return $place;
}

/**
 * Find whether panels and the header/footer areas won't be shown.
 *
 * @return BINARY Result
 */
function is_wide_high() : int
{
    global $IS_WIDE_HIGH_CACHE;
    if ($IS_WIDE_HIGH_CACHE !== null) {
        return $IS_WIDE_HIGH_CACHE;
    }

    $IS_WIDE_HIGH_CACHE = get_param_integer('wide_high', get_param_integer('keep_wide_high', get_param_integer('wide_print', 0)));
    return $IS_WIDE_HIGH_CACHE;
}

/**
 * Find whether panels will be shown.
 *
 * @return BINARY Result
 */
function is_wide() : int
{
    global $IS_WIDE_CACHE;
    if ($IS_WIDE_CACHE !== null) {
        return $IS_WIDE_CACHE;
    }

    global $ZONE;
    $IS_WIDE_CACHE = get_param_integer('wide', get_param_integer('keep_wide', (is_wide_high() == 1) ? 1 : 0));
    if ($IS_WIDE_CACHE == 0) {
        return 0;
    }

    // Need to check it is allowed
    if (get_theme_option('supports_wide') == '0') {
        $IS_WIDE_CACHE = 0;
        return $IS_WIDE_CACHE;
    }

    return $IS_WIDE_CACHE;
}

/**
 * Fixes bad unicode (utf-8) in the input. Useful when input may be dirty, e.g. from a txt file, or from a potential hacker.
 * The fix is imperfect, it will actually treat the input as ISO-8859-1 if not valid utf-8, then reconvert. Some limited scrambling is considered better than a stack trace.
 * This function does nothing if we are not using utf-8.
 *
 * @param  string $input Input string
 * @param  boolean $definitely_unicode If we know the input is meant to be unicode
 * @return string Guaranteed valid utf-8, if we're using it, otherwise the same as the input string
 */
function fix_bad_unicode(string $input, bool $definitely_unicode = false) : string
{
    // Fix bad unicode
    if (get_charset() == 'utf-8' || $definitely_unicode) {
        if (is_numeric($input) || preg_match('#[^\x00-\x7f]#', $input) == 0) {
            return $input; // No non-ASCII characters
        }

        $test_string = $input; // avoid being destructive
        $test_string = preg_replace('#[\x09\x0A\x0D\x20-\x7E]#', '', $test_string); // ASCII
        $test_string = preg_replace('#[\xC2-\xDF][\x80-\xBF]#', '', $test_string); // non-overlong 2-byte
        $test_string = preg_replace('#\xE0[\xA0-\xBF][\x80-\xBF]#', '', $test_string); // excluding overlongs
        $test_string = preg_replace('#[\xE1-\xEC\xEE\xEF][\x80-\xBF]{2}#', '', $test_string); // straight 3-byte
        $test_string = preg_replace('#\xED[\x80-\x9F][\x80-\xBF]#', '', $test_string); // excluding surrogates
        $test_string = preg_replace('#\xF0[\x90-\xBF][\x80-\xBF]{2}#', '', $test_string); // planes 1-3
        $test_string = preg_replace('#[\xF1-\xF3][\x80-\xBF]{3}#', '', $test_string); //  planes 4-15
        $test_string = preg_replace('#\xF4[\x80-\x8F][\x80-\xBF]{2}#', '', $test_string); // plane 16
        if ($test_string !== '') { // All ASCII/unicode characters stripped, so if anything is remaining it must be some kind of corruption
            require_code('character_sets');
            $input = convert_to_internal_encoding($input, 'ISO-8859-1', 'utf-8');
        }
    }
    return $input;
}

/**
 * Get string length, with utf-8 awareness where possible/required.
 *
 * @param  string $in The string to get the length of
 * @param  boolean $force Whether to force unicode as on
 * @return integer The string length
 */
function cms_mb_strlen(string $in, bool $force = false) : int
{
    if (!$force && get_charset() != 'utf-8') {
        return strlen($in);
    }
    if ((function_exists('mb_strlen')) && (function_exists('get_value')) && (get_value('disable_mbstring') !== '1')) {
        return @mb_strlen($in, $force ? 'utf-8' : get_charset()); // @ is because there could be invalid unicode involved
    }
    if ((function_exists('iconv_strlen')) && (function_exists('get_value')) && (get_value('disable_iconv') !== '1')) {
        return @iconv_strlen($in, $force ? 'utf-8' : get_charset());
    }
    return strlen($in);
}

/**
 * Return part of a string, with utf-8 awareness where possible/required.
 *
 * @param  string $in The subject
 * @param  integer $from The start position
 * @param  ?integer $amount The length to extract (null: all remaining)
 * @param  boolean $force Whether to force unicode as on
 * @return ~string String part (false: $start was over the end of the string)
 */
function cms_mb_substr(string $in, int $from, ?int $amount = null, bool $force = false)
{
    if ($amount === null) {
        $amount = cms_mb_strlen($in, $force) - $from;
    }

    if ((!$force) && (get_charset() != 'utf-8')) {
        return substr($in, $from, $amount);
    }

    if ((function_exists('mb_substr')) && (function_exists('get_value')) && (get_value('disable_mbstring') !== '1')) {
        return @mb_substr($in, $from, $amount, $force ? 'utf-8' : get_charset());
    }
    if ((function_exists('iconv_substr')) && (function_exists('get_value')) && (get_value('disable_iconv') !== '1')) {
        return @iconv_substr($in, $from, $amount, $force ? 'utf-8' : get_charset());
    }

    $ret = substr($in, $from, $amount);
    $end = ord(substr($ret, -1));
    if (($end >= 192) && ($end <= 223)) {
        $ret .= substr($in, $from + $amount, 1);
    }
    if ($from != 0) {
        $start = ord(substr($ret, 0, 1));
        if (($start >= 192) && ($start <= 223)) {
            $ret = substr($in, $from - 1, 1) . $ret;
        }
    }
    return $ret;
}

/**
 * Make a string title-case, with utf-8 awareness where possible/required.
 *
 * @param  string $in Subject
 * @return string Result
 */
function cms_mb_ucwords(string $in) : string
{
    if (cms_strtoupper_ascii(get_charset()) == 'ISO-8859-1') {
        $ret = cms_ucwords_ascii($in);
    } elseif (function_exists('mb_convert_case')) {
        $ret = @mb_convert_case($in, MB_CASE_TITLE, get_charset());
    } elseif (function_exists('ucwords')) {
        $ret = ucwords($in); // Relies on locale, which is not ideal
    } else {
        $ret = $in;
    }

    return $ret;
}

/**
 * Make a string's first character upper case, with utf-8 awareness where possible/required.
 *
 * @param  string $in Subject
 * @return string Result
 */
function cms_mb_ucfirst(string $in) : string
{
    if (cms_strtoupper_ascii(get_charset()) == 'ISO-8859-1') {
        $ret = cms_ucfirst_ascii($in);
    } elseif (function_exists('mb_convert_case')) {
        $ret = @mb_convert_case(cms_mb_substr($in, 0, 1), MB_CASE_UPPER, get_charset()) . cms_mb_substr($in, 1);
    } elseif (function_exists('ucfirst')) {
        $ret = ucfirst($in); // Relies on locale, which is not ideal
    } else {
        $ret = $in;
    }

    return $ret;
}

/**
 * Make a string lower case, with utf-8 awareness where possible/required.
 *
 * @param  string $in Subject
 * @return string Result
 */
function cms_mb_strtolower(string $in) : string
{
    if (cms_strtoupper_ascii(get_charset()) == 'ISO-8859-1') {
        $ret = cms_strtolower_ascii($in);
    } elseif (function_exists('mb_strtolower')) {
        $ret = @mb_strtolower($in, get_charset());
    } elseif (function_exists('strtolower')) {
        $ret = strtolower($in); // Relies on locale, which is not ideal
    } else {
        $ret = $in;
    }

    return $ret;
}

/**
 * Make a string upper case, with utf-8 awareness where possible/required.
 *
 * @param  string $in Subject
 * @return string Result
 */
function cms_mb_strtoupper(string $in) : string
{
    if (cms_strtoupper_ascii(get_charset()) == 'ISO-8859-1') {
        $ret = cms_strtoupper_ascii($in);
    } elseif (function_exists('mb_strtoupper')) {
        $ret = @mb_strtoupper($in, get_charset());
    } elseif (function_exists('strtoupper')) {
        $ret = strtoupper($in); // Relies on locale, which is not ideal
    } else {
        $ret = $in;
    }

    return $ret;
}

/**
 * Unicode-safe case-sensitive string comparison.
 * Note we have no cms_mb_strcasecmp because intl (Collator class) cannot do case-insensitive comparison.
 *
 * @param  string $str1 The first string
 * @param  string $str2 The second string
 * @return integer <0 if s1<s2, 0 if s1=s2, >1 if s1>s2
 */
function cms_mb_strcmp(string $str1, string $str2) : int
{
    if ((function_exists('collator_create')) && (function_exists('do_lang'))) {
        $collator = cms_collator_create();
        if ($collator !== null) {
            collator_set_attribute($collator, Collator::NUMERIC_COLLATION, Collator::OFF);

            $cmp = collator_compare($collator, $str1, $str2);

            if ($cmp <= -1) {
                return -1;
            }
            if ($cmp >= 1) {
                return 1;
            }
            return 0;
        }
    }

    // Ideally we'd use strcoll, but that's case-sensitive and also doesn't work on Windows for Unicode, and also relies on unstable collations

    $cmp = strcmp($str1, $str2);

    if ($cmp <= -1) {
        return -1;
    }
    if ($cmp >= 1) {
        return 1;
    }
    return 0;
}

/**
 * Case-sensitive string comparisons using a "natural order" algorithm, Unicode-safe.
 *
 * @param  string $str1 The first string
 * @param  string $str2 The second string
 * @return integer <0 if s1<s2, 0 if s1=s2, >1 if s1>s2
 */
function cms_mb_strnatcmp(string $str1, string $str2) : int
{
    if (function_exists('collator_create')) {
        $collator = cms_collator_create();
        if ($collator !== null) {
            collator_set_attribute($collator, Collator::NUMERIC_COLLATION, Collator::ON);

            $ret = collator_compare($collator, $str1, $str2);

            return $ret;
        }
    }

    $ret = strnatcmp($str1, $str2);

    return $ret;
}

/**
 * Create a collator for the configured locale.
 *
 * @return ?object Collator (null: none)
 */
function cms_collator_create() : ?object
{
    static $collator = null;
    if ($collator !== null) {
        return $collator;
    }
    if (function_exists('collator_create')) {
        $locale_str = do_lang('locale');
        if ($locale_str != '') {
            $locale_sections = explode(';', $locale_str);
            foreach ($locale_sections as $locale_section) {
                $parts = explode(':', $locale_section, 2);
                $locale = $parts[0];
                if (count($parts) == 2) {
                    $locale = $parts[1];
                }
                $collator = collator_create($locale);
                if ($collator !== null) {
                    if (collator_get_locale($collator, Locale::VALID_LOCALE) != 'root') {
                        return $collator;
                    }
                    unset($collator);
                }
            }
        }
    }
    return null;
}

/**
 * Make a string upper case.
 *
 * @param  string $str Subject
 * @return string Result
 */
function cms_strtoupper_ascii(string $str) : string
{
    global $ASCII_LCASE_MAP;
    $ret = '';
    $len = strlen($str);
    for ($i = 0; $i < $len; $i++) {
        $c = $str[$i];
        $ret .= isset($ASCII_LCASE_MAP[$c]) ? $ASCII_LCASE_MAP[$c] : $c;
    }
    return $ret;
}

/**
 * Make a string lower case.
 *
 * @param  string $str Subject
 * @return string Result
 */
function cms_strtolower_ascii(string $str) : string
{
    global $ASCII_UCASE_MAP;
    $ret = '';
    $len = strlen($str);
    for ($i = 0; $i < $len; $i++) {
        $c = $str[$i];
        $ret .= isset($ASCII_UCASE_MAP[$c]) ? $ASCII_UCASE_MAP[$c] : $c;
    }
    return $ret;
}

/**
 * Make a string's first character lower case.
 *
 * @param  string $str Subject
 * @return string Result
 */
function cms_lcfirst_ascii(string $str) : string
{
    return cms_strtolower_ascii(substr($str, 0, 1)) . substr($str, 1);
}

/**
 * Make a string's first character upper case.
 *
 * @param  string $str Subject
 * @return string Result
 */
function cms_ucfirst_ascii(string $str) : string
{
    return cms_strtoupper_ascii(substr($str, 0, 1)) . substr($str, 1);
}

/**
 * Upper case the first character of each word in a string.
 *
 * @param  string $str Subject
 * @return string Result
 */
function cms_ucwords_ascii(string $str) : string
{
    $starting_word = true;
    global $ASCII_LCASE_MAP;
    $ret = '';
    $len = strlen($str);
    for ($i = 0; $i < $len; $i++) {
        $c = $str[$i];
        $is_whitespace = (trim($c) == '');
        if (($starting_word) && (!$is_whitespace)) {
            $ret .= isset($ASCII_LCASE_MAP[$c]) ? $ASCII_LCASE_MAP[$c] : $c;
            $starting_word = false;
        } else {
            if ($is_whitespace) {
                $starting_word = true;
            }
            $ret .= $c;
        }
    }
    return $ret;
}

/**
 * Sort an array of Unicode strings. Assumes SORT_FLAG_CASE because our Unicode sorting cannot do case-sensitive, only the SORT_NATURAL flag does anything.
 *
 * @param  array $array The array
 * @param  integer $sort_flags Sort flags
 */
function cms_mb_sort(array &$array, int $sort_flags = 0)
{
    usort($array, ((($sort_flags & SORT_NATURAL) != 0) ? 'cms_mb_strnatcmp' : 'cms_mb_strcmp'));
}

/**
 * Sort an array of Unicode strings in reverse order. Assumes SORT_FLAG_CASE because our Unicode sorting cannot do case-sensitive, only the SORT_NATURAL flag does anything.
 *
 * @param  array $array The array to sort
 * @param  integer $sort_flags Sort flags
 */
function cms_mb_rsort(array &$array, int $sort_flags = 0)
{
    cms_mb_sort($array, $sort_flags);
    $array = array_reverse($array);
}

/**
 * Sort an array of Unicode strings and maintain index association. Assumes SORT_FLAG_CASE because our Unicode sorting cannot do case-sensitive, only the SORT_NATURAL flag does anything.
 *
 * @param  array $array Array
 * @param  integer $sort_flags Sort flags
 */
function cms_mb_asort(array &$array, int $sort_flags = 0)
{
    uasort($array, ((($sort_flags & SORT_NATURAL) != 0) ? 'cms_mb_strnatcmp' : 'cms_mb_strcmp'));
}

/**
 * Sort an array of Unicode strings in reverse order and maintain index association. Assumes SORT_FLAG_CASE because our Unicode sorting cannot do case-sensitive, only the SORT_NATURAL flag does anything.
 *
 * @param  array $array Array
 * @param  integer $sort_flags Sort flags
 */
function cms_mb_arsort(array &$array, int $sort_flags = 0)
{
    cms_mb_asort($array, $sort_flags);
    $array = array_reverse($array);
}

/**
 * Sort an array by Unicode key. Assumes SORT_FLAG_CASE because our Unicode sorting cannot do case-sensitive, only the SORT_NATURAL flag does anything.
 *
 * @param  array $array The array to sort
 * @param  integer $sort_flags Sort flags
 */
function cms_mb_ksort(array &$array, int $sort_flags = 0)
{
    uksort($array, ((($sort_flags & SORT_NATURAL) != 0) ? 'cms_mb_strnatcmp' : 'cms_mb_strcmp'));
}

/**
 * Sort an array by Unicode key in reverse order. Assumes SORT_FLAG_CASE because our Unicode sorting cannot do case-sensitive, only the SORT_NATURAL flag does anything.
 *
 * @param  array $array The array to sort
 * @param  integer $sort_flags Sort flags
 */
function cms_mb_krsort(array &$array, int $sort_flags = 0)
{
    cms_mb_ksort($array, $sort_flags);
    $array = array_reverse($array);
}

/**
 * Find if we a string is ASCII, and hence we can use non-UTF-safe functions on it.
 *
 * @param  string $x String to test
 * @return boolean Whether it is ASCII
 */
function is_ascii_string(string $x) : bool
{
    $l = strlen($x);
    for ($i = 0; $i < $l; $i++) {
        if (ord($x[$i]) >= 128) {
            return false;
        }
    }
    return true;
}

/**
 * Find whether we have no forum on this website.
 *
 * @return boolean Whether we have no forum on this website
 */
function has_no_forum() : bool
{
    if (get_forum_type() == 'none') {
        return true;
    }
    if ((get_forum_type() == 'cns') && (!addon_installed('cns_forum'))) {
        return true;
    }
    return false;
}

/**
 * Check to see if an addon is installed.
 * Will also include check database tables for addons that are hook-based, just in case filesystem and database got out of sync.
 * For addons with no addon_registry hook can also check the database (if requested via $check_hookless).
 *
 * @param  ID_TEXT $addon_name The addon name
 * @param  boolean $check_hookless Whether to check addons with no addon_registry hook (it's very rare to need this)
 * @param  boolean $deep_scan Do a deep scan of the database to see if the addon is fully installed
 * @param  boolean $disabled_scan Consider whether the addon is set as disabled
 * @return boolean Whether it is
 */
function addon_installed(string $addon_name, bool $check_hookless = false, bool $deep_scan = true, bool $disabled_scan = true) : bool
{
    global $ADDON_INSTALLED_CACHE;
    if (empty($ADDON_INSTALLED_CACHE)) {
        if (!in_safe_mode()) {
            if (function_exists('persistent_cache_get')) {
                $ADDON_INSTALLED_CACHE = persistent_cache_get('ADDONS_INSTALLED');
            }
        }
    }
    if (isset($ADDON_INSTALLED_CACHE[$addon_name][$check_hookless][$deep_scan][$disabled_scan])) {
        return $ADDON_INSTALLED_CACHE[$addon_name][$check_hookless][$deep_scan][$disabled_scan];
    }

    // Check addon_registry hook
    $addon_name = filter_naughty($addon_name, true);
    $answer = is_file(get_file_base() . '/sources/hooks/systems/addon_registry/' . $addon_name . '.php');
    if ((!$answer) && (!in_safe_mode())) {
        $answer = is_file(get_file_base() . '/sources_custom/hooks/systems/addon_registry/' . $addon_name . '.php');
    }

    // Check addons table
    if ((!$GLOBALS['IN_MINIKERNEL_VERSION']) && (!in_safe_mode()) && (!$GLOBALS['DEV_MODE']/*stuff maybe changed during dev*/)) {
        require_code('database');

        if ((!$answer) && ($check_hookless)) {
            $test = $GLOBALS['SITE_DB']->query_select_value_if_there('addons', 'addon_name', ['addon_name' => $addon_name]);
            if ($test !== null) {
                $answer = true;
            }

            // Won't check tables because we don't know them for hookless addons (not in db_meta.bin)
        } else {
            if (($answer) && ($deep_scan)) {
                // Check tables defined in db_meta.bin (bundled addons)
                static $data = null;
                if ($data === null) {
                    $data = is_file(get_file_base() . '/data/db_meta.bin') ? @unserialize(cms_file_get_contents_safe(get_file_base() . '/data/db_meta.bin', FILE_READ_LOCK)) : [];
                }
                if (is_array($data) && array_key_exists('tables', $data)) {
                    foreach ($data['tables'] as $table_name => $table) {
                        if (array_key_exists('addon', $table) && ($table['addon'] == $addon_name)) {
                            $db = get_db_for($table_name);
                            if (!$db->table_exists($table_name)) {
                                $answer = false;
                                break;
                            }
                        }
                    }
                } else { // Corrupt db_meta file; delete it silently as we will notice it in git and can re-generate it
                    @unlink(get_file_base() . '/data/db_meta.bin');
                }
            }
        }

        if (($answer) && ($disabled_scan)) {
            global $VALUES_FULLY_LOADED;
            if (($VALUES_FULLY_LOADED) && (get_value('addon_disabled_' . $addon_name) === '1')) {
                $answer = false;
            }
        }
    }

    $ADDON_INSTALLED_CACHE[$addon_name][$check_hookless][$deep_scan][$disabled_scan] = $answer;
    if (function_exists('persistent_cache_set')) {
        if ((!$GLOBALS['IN_MINIKERNEL_VERSION']) && (!in_safe_mode())) {
            persistent_cache_set('ADDONS_INSTALLED', $ADDON_INSTALLED_CACHE);
        }
    }

    return $answer;
}

/**
 * Check to see if an addon is installed. If not, install return an error message with a link to manage addons.
 *
 * @param  ID_TEXT $addon_name The addon name
 * @param  Tempcode $error_msg Put an error message in here
 * @return boolean Whether it is
 */
function addon_installed__messaged(string $addon_name, object &$error_msg) : bool
{
    if (!addon_installed($addon_name)) {
        $_error_msg = do_lang('MISSING_ADDON', escape_html($addon_name));
        $addon_manage_url = build_url(['page' => 'admin_addons'], get_module_zone('admin_addons'));
        $error_msg = do_lang_tempcode('BROKEN_ADDON_REMEDIES', $_error_msg, escape_html(find_script('upgrader')), escape_html(static_evaluate_tempcode($addon_manage_url)));

        return false;
    }
    return true;
}

/**
 * Convert a float to a "technical string representation of a float". Inverted with floatval.
 *
 * @param  float $num The number
 * @param  integer $decs_wanted The number of decimals to keep
 * @param  boolean $only_needed_decs Whether to trim trailing zeros
 * @return string The string converted
 */
function float_to_raw_string(float $num, int $decs_wanted = 2, bool $only_needed_decs = false) : string
{
    $str = number_format($num, $decs_wanted, '.', '');
    $dot_pos = strpos($str, '.');
    $decs_here = ($dot_pos === false) ? 0 : (strlen($str) - $dot_pos - 1);
    if ($decs_here < $decs_wanted) {
        for ($i = 0; $i < $decs_wanted - $decs_here; $i++) {
            $str .= '0';
        }
    } elseif ($decs_here > $decs_wanted) {
        $str = substr($str, 0, strlen($str) - $decs_here + $decs_wanted);
        if ($decs_wanted == 0 && !$only_needed_decs) {
            $str = rtrim($str, '.');
        }
    }
    if ($only_needed_decs && $decs_wanted != 0) {
        $str = rtrim(rtrim($str, '0'), '.');
    }
    return $str;
}

/**
 * Format the given float number as a nicely formatted string. Inverted with float_unformat.
 *
 * @param  float $val The value to format
 * @param  integer $decs_wanted The number of fractional digits
 * @param  boolean $only_needed_decs Whether to trim trailing zeros
 * @return string Nicely formatted string
 */
function float_format(float $val, int $decs_wanted = 2, bool $only_needed_decs = false) : string
{
    $str = number_format($val, $decs_wanted, do_lang('locale_decimal_point'), do_lang('locale_thousands_sep'));
    $dot_pos = strpos($str, '.');
    $decs_here = ($dot_pos === false) ? 0 : (strlen($str) - $dot_pos - 1);
    if ($decs_here < $decs_wanted) {
        for ($i = 0; $i < $decs_wanted - $decs_here; $i++) {
            $str .= '0';
        }
    } elseif ($decs_here > $decs_wanted) {
        $str = substr($str, 0, strlen($str) - $decs_here + $decs_wanted);
        if ($decs_wanted == 0) {
            $str = rtrim($str, '.');
        }
    }
    if ($only_needed_decs && $decs_wanted != 0) {
        $str = rtrim(rtrim($str, '0'), '.');
    }
    return $str;
}

/**
 * Take the given formatted float number and convert it to a native float. The inverse of float_format.
 *
 * @param  string $str The formatted float number using the locale
 * @param  boolean $includes_thousands_sep Whether we expect a thousands separator, knowing this means we can be a bit smarter
 * @return float Native float
 */
function float_unformat(string $str, bool $includes_thousands_sep = true) : float
{
    // Simplest case?
    if (preg_match('#^\d+$#', $str) != 0) { // E.g. "123"
        return floatval($str);
    }

    if ($includes_thousands_sep) {
        // We can assume a "." is a decimal point then?
        if (preg_match('#^\d+\.\d+$#', $str) != 0) { // E.g. "123.456"
            return floatval($str);
        }
    }

    // Looks like English-format? It couldn't be anything else because thousands_sep always comes before decimal_point
    if (preg_match('#^[\d,]+\.\d+$#', $str) != 0) { // E.g. "123,456.789"
        return floatval($str);
    }

    // Now it must e E.g. "123.456,789" or "123.456", or something from another language which uses other separators...

    if (do_lang('locale_thousands_sep') != '') {
        $str = str_replace(do_lang('locale_thousands_sep'), '', $str);
    }
    $str = str_replace(do_lang('locale_decimal_point'), '.', $str);
    return floatval($str);
}

/**
 * Format the given integer number as a nicely formatted string (using the locale).
 *
 * @param  integer $val The value to format
 * @param  ?integer $dps Number of decimal points to show when simplifying down (null: show the full number)
 * @return string Nicely formatted string
 */
function integer_format(int $val, ?int $dps = null) : string
{
    $ldp = do_lang('locale_decimal_point');
    $lts = do_lang('locale_thousands_sep');

    if ($dps !== null) {
        $units = ['INTEGER_UNITS_billions' => 1000000000, 'INTEGER_UNITS_millions' => 1000000, 'INTEGER_UNITS_thousands' => 1000];
        foreach ($units as $lang_string => $threshold) {
            if ($val >= $threshold) {
                return do_lang($lang_string, number_format(floatval($val) / floatval($threshold), $dps, $ldp, $lts));
            }
        }
    }

    return number_format(floatval($val), 0, $ldp, $lts);
}

/**
 * Require all code relating to the Conversr forum.
 */
function cns_require_all_forum_stuff()
{
    require_lang('cns');

    require_code('cns_members');
    require_code('cns_topics');
    require_code('cns_posts');
    require_code('cns_moderation');
    require_code('cns_groups');
    require_code('cns_forums');
    require_code('cns_general');
}

/**
 * Require all code relating to the core content management.
 * This is a useful shortcut when writing code to create content, e.g. when preparing content to push live programmatically.
 */
function require_all_core_cms_code()
{
    require_code('content2');
    require_code('menus2');
    require_code('permissions2');
    require_code('permissions3');
    require_code('fields');
    if (addon_installed('awards')) {
        require_code('awards');
    }
    require_code('submit');
    require_code('config2');
    require_code('attachments3');
    require_code('zones3');
}

/**
 * Create file with unique file name, but works around compatibility issues between servers. Note that the file is NOT automatically deleted. You should also delete it using "@unlink", as some servers have problems with permissions.
 *
 * @param  string $prefix The prefix of the temporary file name
 * @return ~string The name of the temporary file (false: error)
 */
function cms_tempnam(string $prefix = 'cms')
{
    require_code('files2');
    return _cms_tempnam($prefix);
}

/**
 * Parse a URL and return its components.
 * Wrapper around parse_url that adds standard defaults for missing components.
 *
 * @param  string $url The URL to parse
 * @param  integer $component The component to get (-1 get all in an array)
 * @return ?~mixed A map of details about the URL (false: URL cannot be parsed) (null: missing component)
 */
function cms_parse_url_safe(string $url, int $component = -1)
{
    $ret = parse_url($url, $component);

    if ($component != -1) {
        if ($ret === null) {
            switch ($component) {
                case PHP_URL_SCHEME:
                    return 'http';

                case PHP_URL_PORT:
                    return (cms_strtolower_ascii(cms_parse_url_safe($url, PHP_URL_SCHEME)) == 'https') ? 443 : 80;

                case PHP_URL_PATH:
                case PHP_URL_QUERY:
                case PHP_URL_FRAGMENT:
                    return '';
            }
        }
    } else {
        if (!array_key_exists('scheme', $ret)) {
            $ret['scheme'] = 'http';
        }

        if (!array_key_exists('port', $ret)) {
            $ret['port'] = (cms_strtolower_ascii($ret['scheme']) == 'https') ? '443' : '80';
        }

        if (!array_key_exists('path', $ret)) {
            $ret['path'] = '';
        }
        if (!array_key_exists('query', $ret)) {
            $ret['query'] = '';
        }
        if (!array_key_exists('fragment', $ret)) {
            $ret['fragment'] = '';
        }
    }

    return $ret;
}

/**
 * Make a value suitable for use in an XML ID.
 *
 * @param  string $param The value to escape
 * @param  boolean $simplified Generate simplified IDs
 * @return string The escaped value
 */
function fix_id(string $param, bool $simplified = false) : string
{
    if (preg_match('#^[A-Za-z][\w]*$#', $param) !== 0) {
        return $param; // Optimisation
    }

    if ($simplified) {
        if (get_charset() == 'utf-8') {
            $param = str_replace("\u{0394}", 'delta', $param);
        }
    }

    $length = strlen($param);
    $new = '';
    $flip_case_previous = false;
    for ($i = 0; $i < $length; $i++) {
        $char = $param[$i];
        $flip_case = true;
        switch ($char) {
            case '[':
                $new .= $simplified ? '' : '_opensquare_';
                break;
            case ']':
                $new .= $simplified ? '' : '_closesquare_';
                break;
            case '&#039;':
            case '\'':
                $new .= $simplified ? '' : '_apostophe_';
                break;
            case '-':
                $new .= $simplified ? '' : '_minus_';
                break;
            case ' ':
                $new .= $simplified ? '' : '_space_';
                break;
            case '+':
                $new .= $simplified ? '' : '_plus_';
                break;
            case '*':
                $new .= $simplified ? '' : '_star_';
                break;
            case '/':
                $new .= $simplified ? '' : '__';
                break;
            case '%':
                $new .= '_percent_';
                break;
            default:
                if (($simplified) && (in_array($char, ['(' , ')', ',']))) {
                    break;
                }
                $ascii = ord($char);
                if (($i !== 0) && ($char === '_')) {
                    $new .= '_';
                } elseif ((($ascii >= 48) && ($ascii <= 57)) || (($ascii >= 65) && ($ascii <= 90)) || (($ascii >= 97) && ($ascii <= 122))) {
                    // Alphanumeric
                    $flip_case = false;
                    $new .= ($flip_case_previous && $simplified) ? cms_strtoupper_ascii($char) : $char;
                } elseif (!$simplified) {
                    $new .= '_' . strval($ascii) . '_';
                }
                break;
        }
        $flip_case_previous = $flip_case;
    }

    if ($simplified) {
        $new = str_replace('_', '', $new);
    }

    if ($new === '') {
        $new = 'zero_length';
    }
    if ($new[0] === '_') {
        $new = 'und_' . $new;
    }

    return $new;
}

/**
 * See if the current URL matches the given Composr match-keys.
 *
 * @param  mixed $match_keys Match keys (comma-separated list of match-keys, or array of)
 * @param  boolean $support_post Check against POSTed data too
 * @param  ?array $current_params Parameters to check against (null: get from environment GET/POST) - if set, $support_post is ignored)
 * @param  ?ID_TEXT $current_zone_name Current zone name (null: get from environment)
 * @param  ?ID_TEXT $current_page_name Current page name (null: get from environment)
 * @return boolean Whether there is a match
 */
function match_key_match($match_keys, bool $support_post = false, ?array $current_params = null, ?string $current_zone_name = null, ?string $current_page_name = null) : bool
{
    $req_func = $support_post ? 'either_param_string' : 'get_param_string';

    if ($current_zone_name === null) {
        global $IN_SELF_ROUTING_SCRIPT;
        if (!$IN_SELF_ROUTING_SCRIPT) {
            return false;
        }

        $current_zone_name = get_zone_name();
    }
    if ($current_page_name === null) {
        $current_page_name = get_page_name();
    }

    static $cache = [];
    $sz_key = serialize([$match_keys, $support_post, $current_params, $current_zone_name, $current_page_name]);
    if (isset($cache[$sz_key])) {
        return $cache[$sz_key];
    }

    $potentials = is_array($match_keys) ? $match_keys : explode(',', $match_keys);
    foreach ($potentials as $potential) {
        $parts = is_array($potential) ? $potential : explode(':', $potential);

        // Allow the first 2 parts to be omitted, but if so we need to insert them back in here
        $prepend_zone = ((!isset($parts[0])) || (strpos($parts[0], '=') !== false));
        if ($prepend_zone) {
            $parts = array_merge(['_WILD'], $parts);
        }
        $prepend_page = ((!isset($parts[1])) || (strpos($parts[1], '=') !== false));
        if ($prepend_page) {
            $parts = array_merge(array_slice($parts, 0, 1), ['_WILD'], array_slice($parts, 1));
        }

        if (($parts[0] == '_WILD') || ($parts[0] == '_SEARCH')) {
            $parts[0] = $current_zone_name;
        }
        if (($parts[1] == '_WILD') || (($parts[1] == '_WILD_NOT_START') && ($current_page_name != get_zone_default_page($parts[0])))) {
            $parts[1] = $current_page_name;
        }
        if (($parts[0] == 'site') && (get_option('single_public_zone') == '1')) {
            $parts[0] = '';
        }
        $zone_matches = (($parts[0] == $current_zone_name) || ((strpos($parts[0], '*') !== false) && (simulated_wildcard_match($current_zone_name, $parts[0], true))));
        $page_matches = ((($parts[1] == '') && ($current_page_name == get_zone_default_page($current_zone_name))) || ($parts[1] == $current_page_name) || ((strpos($parts[1], '*') !== false) && (simulated_wildcard_match($current_page_name, $parts[1], true))));
        if (($zone_matches) && ($page_matches)) {
            $bad = false;
            for ($i = 2; $i < count($parts); $i++) {
                if ($parts[$i] != '') {
                    if (($i == 2) && (strpos($parts[$i], '=') === false)) {
                        $parts[$i] = 'type=' . $parts[$i];
                    } elseif (($i == 3) && (strpos($parts[$i], '=') === false)) {
                        $parts[$i] = 'id=' . $parts[$i];
                    }
                }

                $subparts = explode('=', $parts[$i]);
                if ($subparts[0] == 'type') {
                    $default = 'browse';
                } else {
                    $default = '';
                }
                if (count($subparts) != 2) {
                    $bad = true;
                    continue;
                }
                $env_val = ($current_params === null) ? call_user_func_array($req_func, [$subparts[0], null]) : (isset($current_params[$subparts[0]]) ? $current_params[$subparts[0]] : null);
                if ($subparts[1] == '_WILD') {
                    if ($env_val !== null) {
                        $subparts[1] = $env_val; // null won't match to a wildcard
                    }
                } else {
                    if ($env_val === null) {
                        $env_val = $default;
                    }
                }
                if ($env_val !== $subparts[1]) {
                    $bad = true;
                    continue;
                }
            }
            if (!$bad) {
                $cache[$sz_key] = true;
                return true;
            }
        }
    }
    $cache[$sz_key] = false;
    return false;
}

/**
 * Get the name of the page in the URL or active script.
 *
 * @return ID_TEXT The current page/script name
 */
function get_page_or_script_name() : string
{
    global $IN_SELF_ROUTING_SCRIPT;
    if ($IN_SELF_ROUTING_SCRIPT) {
        return get_page_name();
    }
    return current_script();
}

/**
 * Get the name of the page in the URL (by convention: the current page).
 * This works on the basis of the 'page' parameter and does not require index.php be the active script.
 * It will do dash to underscore substitution as required.
 *
 * @return ID_TEXT The current page name
 */
function get_page_name() : string
{
    global $PAGE_NAME_CACHE;
    if (isset($PAGE_NAME_CACHE)) {
        return $PAGE_NAME_CACHE;
    }
    global $ZONE, $BOOTSTRAPPING;
    static $getting_page_name = false;
    if ($getting_page_name) {
        return 'unknown';
    }
    $getting_page_name = true;
    $page = get_param_string('page', '', INPUT_FILTER_GET_COMPLEX);
    if (strlen($page) > 80) {
        warn_exit(do_lang_tempcode('INTERNAL_ERROR'));
    }
    if (($page == '') && ($ZONE !== null)) {
        $page = $ZONE['zone_default_page'];
        if ($page === null) {
            $page = '';
        }
    }
    if (strpos($page, '..') !== false) {
        $page = filter_naughty($page);
    }
    $simplified_algorithm = $BOOTSTRAPPING; // fix_page_name_dashing calls request_page, which won't work reliably during bootstrapping
    if ($simplified_algorithm) {
        $page = str_replace('-', '_', $page);
    } else {
        $page = fix_page_name_dashing(get_zone_name(), $page);
    }
    if (!$getting_page_name) { // It's been changed by process_url_monikers, which was called indirectly by fix_page_name_dashing
        return $PAGE_NAME_CACHE;
    }
    if (($ZONE !== null) && (!$simplified_algorithm)) {
        $PAGE_NAME_CACHE = $page;
    }
    $getting_page_name = false;
    return $page;
}

/**
 * Fix a page name that may have been given dashes for SEO reasons.
 *
 * @param  string $zone Zone
 * @param  string $page Page
 * @return string The fixed page name
 */
function fix_page_name_dashing(string $zone, string $page) : string
{
    if (strpos($page, '/') !== false) {
        return $page; // It's a moniker that hasn't been processed yet
    }

    // Fix page-name dashes if needed
    if (strpos($page, '-') !== false) {
        require_code('site');
        $test = _request_page($page, $zone, null, null, false);
        if ($test === false) {
            $_page = str_replace('-', '_', $page);
            $test = _request_page($_page, $zone);
            if ($test !== false) {
                $page = $_page;
            }
        }
    }
    return $page;
}

/**
 * Peek at a stack element.
 *
 * @param  array $array The stack to peek in
 * @param  integer $depth_down The depth into the stack we are peaking
 * @return mixed The result of the peeking
 */
function array_peek(array $array, int $depth_down = 1)
{
    $count = count($array);
    if ($count - $depth_down < 0) {
        return null;
    }
    return $array[$count - $depth_down];
}

/**
 * Take a list of maps, and make one of the values of each array the index of a map to the map.
 *
 * list_to_map is very useful for handling query results.
 * Let's imagine you get the result of SELECT id,title FROM sometable.
 * list_to_map turns the array of rows into a map between the id key and each row.
 *
 * @param  ?string $map_value The key key of our maps that reside in our map (null: serialize the whole map)
 * @param  array $list The list of maps
 * @return array The collapsed map
 */
function list_to_map(?string $map_value, array $list) : array
{
    $i = 0;

    $new_map = [];

    foreach ($list as $map) {
        $key = ($map_value === null) ? serialize($map) : $map[$map_value];
        $new_map[$key] = $map;

        $i++;
    }

    if ($i > 0) {
        return $new_map;
    }
    return [];
}

/**
 * Take a list of maps of just two elements, and make it into a single map.
 *
 * @param  string $key The key key of our maps that reside in our map
 * @param  string $value The value key of our maps that reside in our map
 * @param  array $list The map of maps
 * @return array The collapsed map
 */
function collapse_2d_complexity(string $key, string $value, array $list) : array
{
    return array_column($list, $value, $key);
}

/**
 * Take a list of maps of just one element, and make it into a single map.
 *
 * @param  ?string $key The key of our maps that reside in our map (null: first key)
 * @param  array $list The map of maps
 * @return array The collapsed map
 */
function collapse_1d_complexity(?string $key, array $list) : array
{
    if ($key !== null) {
        return array_column($list, $key); // Faster
    }

    $new_array = [];
    foreach ($list as $map) {
        if ($key === null) {
            $new_array[] = array_shift($map);
        } else {
            $new_array[] = $map[$key];
        }
    }

    return $new_array;
}

/**
 * Sort a list of maps by the string length of a particular key ID in the maps.
 *
 * @param  array $rows List of maps to sort
 * @param  mixed $sort_key Either an integer sort key (to sort by integer key ID of contained arrays) or a String sort key (to sort by string key ID of contained arrays)
 */
function sort_maps_by__strlen(array $rows, $sort_key)
{
    global $M_SORT_KEY;
    $M_SORT_KEY = $sort_key;

    if (count($rows) < 2) {
        if (($GLOBALS['DEV_MODE']) && (count($rows) == 1)) {
            call_user_func('_strlen_sort', current($rows), current($rows)); // Just to make sure there's no crash bug in the sort function
        }
        return;
    }

    uasort($rows, '_strlen_sort');
}

/**
 * Helper function for usort to sort a list by string length.
 *
 * @param  string $a The first string to compare
 * @param  string $b The second string to compare
 * @return integer The comparison result (0 for equal, -1 for less, 1 for more)
 * @ignore
 */
function _strlen_sort(string $a, string $b) : int
{
    if (!isset($a)) {
        $a = '';
    }
    if (!isset($b)) {
        $b = '';
    }
    if ($a == $b) {
        return 0;
    }
    if (!is_string($a)) {
        global $M_SORT_KEY;
        return (strlen($a[$M_SORT_KEY]) < strlen($b[$M_SORT_KEY])) ? -1 : 1;
    }
    return (strlen($a) < strlen($b)) ? -1 : 1;
}

/**
 * Helper function for usort to sort a list by string length in reverse order.
 *
 * @param  string $a The first string to compare
 * @param  string $b The second string to compare
 * @return integer The comparison result (0 for equal, -1 for less, 1 for more)
 * @ignore
 */
function _strlen_reverse_sort(string $a, string $b) : int
{
    if (!isset($a)) {
        $a = '';
    }
    if (!isset($b)) {
        $b = '';
    }
    if ($a == $b) {
        return 0;
    }
    if (!is_string($a)) {
        global $M_SORT_KEY;
        return (strlen($a[$M_SORT_KEY]) > strlen($b[$M_SORT_KEY])) ? -1 : 1;
    }
    return (strlen($a) > strlen($b)) ? -1 : 1;
}

/**
 * Sort a list of maps by a particular key ID in the maps. Does not (and should not) preserve list indices, but does preserve associative key indices.
 *
 * @param  array $rows List of maps to sort
 * @param  mixed $sort_keys Either an integer sort key (to sort by integer key ID of contained arrays) or a Comma-separated list of sort keys (to sort by string key ID of contained arrays; prefix '!' a key to reverse the sort order for it)
 * @param  boolean $preserve_order_if_possible Don't shuffle order unnecessarily (i.e. do a merge sort)
 * @param  boolean $natural Whether to do a natural sort
 */
function sort_maps_by(array &$rows, $sort_keys, bool $preserve_order_if_possible = false, bool $natural = false)
{
    if (empty($rows)) {
        return;
    }

    global $M_SORT_KEY, $M_SORT_NATURAL;
    $M_SORT_KEY = $sort_keys;
    $M_SORT_NATURAL = $natural;
    if ($preserve_order_if_possible) {
        merge_sort($rows, '_multi_sort');
    } else {
        if (count($rows) < 2) {
            if (($GLOBALS['DEV_MODE']) && (count($rows) == 1)) {
                call_user_func('_multi_sort', current($rows), current($rows)); // Just to make sure there's no crash bug in the sort function
            }
            return;
        }

        $first_key = key($rows);
        if ((is_integer($first_key)) && (array_unique(array_map('is_integer', array_keys($rows))) === [true])) {
            usort($rows, '_multi_sort');
        } else {
            uasort($rows, '_multi_sort');
        }
    }
}

/**
 * Do a user sort, preserving order where reordering not needed. Based on a PHP manual comment at http://php.net/manual/en/function.usort.php.
 * Roughly equivalent to PHP's uasort.
 *
 * @param  array $array Sort array
 * @param  mixed $cmp_function Comparison function
 */
function merge_sort(array &$array, $cmp_function = 'cms_mb_strcmp')
{
    // Arrays of size<2 require no action.
    if (count($array) < 2) {
        if (($GLOBALS['DEV_MODE']) && (count($array) == 1)) {
            call_user_func($cmp_function, current($array), current($array)); // Just to make sure there's no crash bug in the sort function
        }

        return;
    }

    // Split the array in half
    $halfway = intval(floatval(count($array)) / 2.0);
    $array1 = array_slice($array, 0, $halfway);
    $array2 = array_slice($array, $halfway);

    // Recurse to sort the two halves
    merge_sort($array1, $cmp_function);
    merge_sort($array2, $cmp_function);

    // If all of $array1 is <= all of $array2, just append them.
    if (call_user_func($cmp_function, end($array1), reset($array2)) < 1) {
        $array = array_merge($array1, $array2);
        return;
    }

    // Merge the two sorted arrays into a single sorted array
    $array = [];
    reset($array1);
    reset($array2);
    $ptr1 = 0;
    $ptr2 = 0;
    $cnt1 = count($array1);
    $cnt2 = count($array2);
    while (($ptr1 < $cnt1) && ($ptr2 < $cnt2)) {
        if (call_user_func($cmp_function, current($array1), current($array2)) < 1) {
            $key = key($array1);
            if (is_integer($key)) {
                $array[] = current($array1);
            } else {
                $array[$key] = current($array1);
            }
            $ptr1++;
            next($array1);
        } else {
            $key = key($array2);
            if (is_integer($key)) {
                $array[] = current($array2);
            } else {
                $array[$key] = current($array2);
            }
            $ptr2++;
            next($array2);
        }
    }

    // Merge the remainder
    while ($ptr1 < $cnt1) {
        $key = key($array1);
        if (is_integer($key)) {
            $array[] = current($array1);
        } else {
            $array[$key] = current($array1);
        }
        $ptr1++;
        next($array1);
    }
    while ($ptr2 < $cnt2) {
        $key = key($array2);
        if (is_integer($key)) {
            $array[] = current($array2);
        } else {
            $array[$key] = current($array2);
        }
        $ptr2++;
        next($array2);
    }
}

/**
 * Helper function to sort a list of maps by the value at $key in each of those maps.
 *
 * @param  array $a The first to compare
 * @param  array $b The second to compare
 * @return integer The comparison result (0 for equal, -1 for less, 1 for more)
 * @ignore
 */
function _multi_sort(array $a, array $b) : int
{
    global $M_SORT_KEY, $M_SORT_NATURAL;
    $keys = explode(',', is_string($M_SORT_KEY) ? $M_SORT_KEY : strval($M_SORT_KEY));
    $first_key = $keys[0];
    if ($first_key[0] === '!') {
        $first_key = substr($first_key, 1);
    }

    if (($a === null) && ($b === null)) {
        return 0;
    } elseif ($a === null) {
        return ($first_key[0] === '!') ? 1 : -1;
    } elseif ($b === null) {
        return ($first_key[0] === '!') ? -1 : 1;
    }

    $key_cnt = count($keys);

    // String version
    if ((is_string($a[$first_key])) || (is_object($a[$first_key]))) {
        $ret = 0;
        do {
            $key = array_shift($keys);
            $key_cnt--;

            $backwards = ($key[0] === '!');
            if ($backwards) {
                $key = substr($key, 1);
            }

            $av = $a[$key];
            $bv = $b[$key];

            if (is_object($av)) {
                $av = $av->evaluate();
            }
            if (is_object($bv)) {
                $bv = $bv->evaluate();
            }

            if ((is_numeric($av)) && (is_numeric($bv)) || $M_SORT_NATURAL) {
                $ret = cms_mb_strnatcmp(@strval($av), @strval($bv));
            } else {
                $ret = cms_mb_strcmp(@strval($av), @strval($bv));
            }

            if ($backwards) {
                $ret *= -1;
            }
        } while (($key_cnt !== 0) && ($ret === 0));
        return $ret;
    }

    // Non-string version
    do {
        $key = array_shift($keys);
        $key_cnt--;

        $backwards = ($key[0] === '!');
        if ($backwards) {
            $key = substr($key, 1);
        }

        $av = $a[$key];
        $bv = $b[$key];

        $ret = ($av > $bv) ? 1 : (($av == $bv) ? 0 : -1);

        if ($backwards) {
            $ret *= -1;
        }
    } while (($key_cnt !== 0) && ($ret === 0));
    return $ret;
}

/**
 * Strip HTML and PHP tags from a string.
 * Equivalent to PHP's strip_tags, except adds support for tags_as_allow being false.
 *
 * @param  string $str Subject
 * @param  string $tags Comma-separated list of tags (blank: none)
 * @param  boolean $tags_as_allow Whether tags represents a safelist (set for false to allow all by default and make $tags a blocklist)
 * @return string Result
 */
function cms_strip_tags(string $str, string $tags = '', bool $tags_as_allow = true) : string
{
    global $STRIP_TAGS_TAGS, $STRIP_TAGS_TAGS_AS_ALLOW;
    $STRIP_TAGS_TAGS = $tags;
    $STRIP_TAGS_TAGS_AS_ALLOW = $tags_as_allow;

    return preg_replace_callback('#</?([^\s<>]+)(\s[^<>]*)?' . '>#', '_cms_strip_tags_callback', $str);
}

/**
 * Used by cms_strip_tags to handle whether to strip a tag.
 *
 * @param  array $matches Array of matches
 * @return string Substituted tag text
 *
 * @ignore
 */
function _cms_strip_tags_callback(array $matches) : string
{
    global $STRIP_TAGS_TAGS, $STRIP_TAGS_TAGS_AS_ALLOW;
    $tag_covered = stripos($STRIP_TAGS_TAGS, '<' . $matches[1] . '>');
    if ((($STRIP_TAGS_TAGS_AS_ALLOW) && ($tag_covered !== false)) || ((!$STRIP_TAGS_TAGS_AS_ALLOW) && ($tag_covered === false))) {
        return $matches[0];
    }
    return '';
}

/**
 * Attempt to get the clean IP address of the current user.
 * Note we do not consider potential proxying because that can easily be forged, and even if not it could be some local IP that is not unique enough.
 *  Composr has support for deproxying in global.php, which will adjust REMOTE_ADDR well in advance of this function being called.
 *
 * @param  integer $amount The number of groups to include in the IP address (rest will be replaced with *'s). For IP6, this is doubled.
 * @set 1 2 3 4
 * @param  ?IP $ip IP address to use, normally left null (null: current user's)
 * @return IP The users IP address (blank: could not find a valid one)
 */
function get_ip_address(int $amount = 4, ?string $ip = null) : string
{
    if ($ip === null) {
        $ip = $_SERVER['REMOTE_ADDR'];
    }

    if ($ip == '') {
        $ip = '127.0.0.1'; // Running on CLI, perhaps
    }

    global $SITE_INFO;
    if (($amount == 3) && (!empty($SITE_INFO['full_ips']))) { // Extra configurable security
        $amount = 4;
    }

    return normalise_ip_address($ip, $amount);
}

/**
 * Get possible hostnames of a localhost machine.
 * Also see get_server_names_and_ips().
 *
 * @return array Hostnames and IP addresses
 */
function get_localhost_names() : array
{
    return [
        'localhost',
    ];
}

/**
 * Get possible hostnames and IP addresses of a localhost machine.
 * Also see get_server_names().
 *
 * @return array Hostnames
 */
function get_localhost_names_and_ips() : array
{
    return array_unique(array_merge(get_localhost_ips(), get_localhost_names()));
}

/**
 * See if an IP address is local (localhost or on non-routable LAN).
 *
 * @param  IP $ip_address IP address
 * @return boolean Whether the IP address is local
 */
function ip_address_is_local(string $ip_address) : bool
{
    return ((in_array($ip_address, get_localhost_ips())) || (substr($ip_address, 0, 3) == '10.') || (substr($ip_address, 0, 8) == '192.168.'));
}

/**
 * Find whether a machine is local (localhost or on non-routable LAN), rather than a live-site.
 * Also see is_our_server().
 *
 * @param  ?IP $ip_or_hostname IP address or hostname (null: currently requested hostname)
 * @return boolean If it is running locally
 */
function is_local_machine(?string $ip_or_hostname = null) : bool
{
    if ($ip_or_hostname === null) {
        $ip_or_hostname = get_request_hostname();
    }
    return (ip_address_is_local($ip_or_hostname)) || (in_array($ip_or_hostname, get_localhost_names()));
}

/**
 * Make our best guess on what IP addresses connections from server to server will use.
 * Also see get_server_ips().
 *
 * @return IP IP address
 */
function get_server_external_looparound_ip() : string
{
    if (get_option('ip_forwarding') == '1') {
        $server_ips = get_server_ips(true);
        $ip_address = $server_ips[0];
    } else {
        $ip_address = cms_gethostbyname(get_base_url_hostname());
    }
    return $ip_address;
}

/**
 * Get possible IP addresses of the server.
 * In order of what is most likely what the server considers itself. The first entry may be used for server loopback connections.
 * Also see get_localhost_ips().
 *
 * @param  boolean $local_interface_only Whether to only get IP addresses that are on a local network interface
 * @return array IP addresses
 */
function get_server_ips(bool $local_interface_only = false) : array
{
    static $arr = null;

    if ($arr === null) {
        $arr = [];

        if (!empty($_SERVER['SERVER_ADDR'])) {
            $arr[] = $_SERVER['SERVER_ADDR'];
        }
        if (!empty($_SERVER['LOCAL_ADDR'])) {
            $arr[] = $_SERVER['LOCAL_ADDR'];
        }

        if (!$local_interface_only) {
            $hostnames = get_server_names(false);
            foreach ($hostnames as $hostname) {
                $test = cms_gethostbyname($hostname);
                if ($test != $hostname) {
                    $arr[] = $test;
                }
            }
        }

        $arr = array_merge($arr, get_localhost_ips());

        $arr = array_unique($arr);
    }

    return $arr;
}

/**
 * Get possible hostnames of the server.
 * In order of what is most likely what the server considers itself.
 * Also see get_domain() and get_request_hostname() and get_base_url_hostname() and get_localhost_names().
 *
 * @param  boolean $include_non_web_names Whether to include names that may not be for a web domain
 * @param  boolean $include_equivalents Whether to include www vs non-www equivalents
 * @return array Host names
 */
function get_server_names(bool $include_non_web_names = true, bool $include_equivalents = true) : array
{
    $arr = [];
    if ($include_non_web_names) {
        if ($_SERVER['SERVER_NAME'] != '') {
            $arr[] = $_SERVER['SERVER_NAME'];
        }
    }
    if (!empty($_SERVER['HTTP_HOST'])) {
        $arr[] = preg_replace('#:.*#', '', $_SERVER['HTTP_HOST']);
    }
    if ($include_non_web_names) {
        $arr[] = gethostname();
    }
    global $SITE_INFO;
    if (!empty($SITE_INFO['domain'])) {
        $arr[] = $SITE_INFO['domain'];
    }
    if (!empty($SITE_INFO['base_url'])) {
        $tmp = cms_parse_url_safe($SITE_INFO['base_url'], PHP_URL_HOST);
        if (!empty($tmp)) {
            $arr[] = $tmp;
        }
    }
    if (get_custom_base_url() != get_base_url()) {
        $tmp = cms_parse_url_safe(get_custom_base_url(), PHP_URL_HOST);
        if (!empty($tmp)) {
            $arr[] = $tmp;
        }
    }
    $zl = strlen('ZONE_MAPPING_');
    foreach ($SITE_INFO as $key => $_val) {
        if ($key !== '' && $key[0] === 'Z' && substr($key, 0, $zl) === 'ZONE_MAPPING_') {
            $arr[] = $_val[0];
        }
    }
    if ($include_non_web_names) {
        $arr = array_merge($arr, get_localhost_names());
    }

    $arr = array_unique($arr);

    $arr2 = [];
    foreach ($arr as $domain) {
        $arr2[] = $domain;

        // www vs non-www equivalents
        if ($include_equivalents) {
            if (preg_match('#^[\d:.]$#', $domain) == 0) { // If not an IP address
                if (preg_match('#^www\.#', $domain) != 0) {
                    $arr2[] = substr($domain, 4);
                } else {
                    $arr2[] = 'www.' . $domain;
                }
            }
        }
    }

    return array_unique($arr2);
}

/**
 * Get possible hostnames of the server.
 * Also see get_localhost_names().
 *
 * @return array Hostnames and IP addresses
 */
function get_server_names_and_ips() : array
{
    return array_unique(array_merge(get_server_ips(), get_server_names()));
}

/**
 * Find whether a machine is our server.
 * Also see is_local_machine().
 *
 * @param  ?IP $ip_or_hostname IP address or hostname (null: current user's IP address)
 * @return boolean If it is our server
 */
function is_our_server(?string $ip_or_hostname = null) : bool
{
    if ($ip_or_hostname === null) {
        $ip_or_hostname = get_ip_address();
    }
    return in_array($ip_or_hostname, get_server_names_and_ips());
}

/**
 * Exit with debug data, only for a specific IP address.
 *
 * @param  IP $ip IP address of tester
 * @param  mixed $data Data to display (no HTML escaping is done in this function)
 */
function me_debug(string $ip, $data)
{
    if (get_ip_address() == $ip) {
        @var_dump($data);
        exit();
    }
}

/**
 * Get a string of the users web browser.
 *
 * @return string The web browser string
 */
function get_browser_string() : string
{
    static $ret = null;
    if ($ret === null) {
        $ret = $_SERVER['HTTP_USER_AGENT'];
        $ret = str_replace(' (' . get_os_string() . ')', '', $ret);
    }
    return $ret;
}

/**
 * Get the user's operating system.
 *
 * @return string The operating system string
 */
function get_os_string() : string
{
    static $ret = null;
    if ($ret === null) {
        $ret = '';
        // E.g. Mozilla/4.5 [en] (X11; U; Linux 2.2.9 i586)
        // We need to get the stuff in the parentheses
        $matches = [];
        if (preg_match('#\(([^\)]*)\)#', $_SERVER['HTTP_USER_AGENT'], $matches) != 0) {
            $ret = $matches[1];
            $ret = preg_replace('#^compatible; (MSIE[^;]*; )?#', '', $ret);
        }
    }
    return $ret;
}

/**
 * Find if the system scheduler is installed.
 *
 * @param  boolean $absolutely_sure Whether the system scheduler really needs to be installed (if set to false it will be assumed installed on dev-mode)
 * @return boolean Whether The system scheduler is installed
 */
function cron_installed(bool $absolutely_sure = false) : bool
{
    $test = get_param_integer('keep_has_cron', null);
    if ($test !== null) {
        return $test == 1;
    }

    if (!$absolutely_sure) {
        if ($GLOBALS['DEV_MODE']) {
            return true;
        }
    }

    $last_cron = get_value('last_cron');
    if ($last_cron === null) {
        return false;
    }
    return intval($last_cron) > (time() - 60 * 60 * 5);
}

/**
 * Compare two IP addresses for potential correlation. Not as simple as equality due to '*' syntax.
 *
 * @param  string $wild The general IP address that is potentially wildcarded
 * @param  IP $full The specific IP address we are checking
 * @return boolean Whether the IP addresses correlate
 */
function compare_ip_address(string $wild, string $full) : bool
{
    $full = normalise_ip_address($full);
    if ($full == '') {
        return false;
    }
    if (strpos($full, '.') !== false) {
        return _compare_ip_address($wild, explode('.', $full), '.');
    }
    return _compare_ip_address($wild, explode(':', $full), ':');
}

/**
 * Compare two IP addresses for potential correlation, pre-exploded. Not as simple as equality due to '*' syntax.
 *
 * @param  string $wild The general IP address that is potentially wildcarded
 * @param  array $full_parts The exploded parts of the specific IP address we are checking
 * @param  string $delimiter The delimiter
 * @return boolean Whether the IP addresses correlate
 */
function _compare_ip_address(string $wild, array $full_parts, string $delimiter) : bool
{
    $wild = normalise_ip_address($wild, 4);
    if ($wild == '') {
        return false;
    }
    $wild_parts = explode($delimiter, $wild);
    foreach ($wild_parts as $i => $wild_part) {
        if (($wild_part != '*') && ($wild_part != $full_parts[$i])) {
            return false;
        }
    }
    return true;
}

/**
 * Check to see if an IP address is banned.
 *
 * @param  string $ip The IP address to check for banning
 * @param  boolean $force_db Force check via database only
 * @param  boolean $handle_uncertainties Handle uncertainties (used for the external bans - if true, we may return null, showing we need to do an external check). Only works with $force_db.
 * @param  ?boolean $is_unbannable Whether the IP is unbannable (returned by reference) (null: not set yet by caller)
 * @param  ?integer $ban_until When the ban will expire, will always be more than the current timestamp (null: not set yet by caller / no expiration)
 * @param  boolean $check_caching Whether to check internal run-time caching (disable if doing automated tests)
 * @return ?boolean Whether the IP address is banned (null: unknown)
 */
function ip_banned(string $ip, bool $force_db = false, bool $handle_uncertainties = false, ?bool &$is_unbannable = null, ?int &$ban_until = null, bool $check_caching = true) : ?bool
{
    // NB: This function will make the first query called, so we will be a bit smarter, checking for errors

    $is_unbannable = false;
    $ban_until = null;

    static $cache = [];

    $sz = serialize([$force_db, $handle_uncertainties]);
    if ((isset($cache[$sz][$ip])) && ($check_caching)) {
        $is_unbannable = $cache[$sz][$ip]['is_unbannable'];
        $ban_until = $cache[$sz][$ip]['ban_until'];
        return $cache[$sz][$ip]['ret'];
    }

    // Edge case: No securitylogging addon
    if (!addon_installed('securitylogging')) {
        $ret = false;
        $cache[$sz][$ip] = [
            'is_unbannable' => $is_unbannable,
            'ban_until' => $ban_until,
            'ret' => $ret,
        ];
        return $ret;
    }

    // Edge case: Commonly invalid IP
    if ($ip == '') {
        $ret = false;
        $cache[$sz][$ip] = [
            'is_unbannable' => $is_unbannable,
            'ban_until' => $ban_until,
            'ret' => $ret,
        ];
        return $ret;
    }

    // Edge case: Excluded via config option (different from checking i_ban_positive===false, as this option is referenced in antispam.php too)
    $_exclusions = get_option('spam_check_exclusions');
    $exclusions = explode(',', $_exclusions);
    foreach ($exclusions as $exclusion) {
        if (trim($ip) == $exclusion) {
            $ret = false;
            $is_unbannable = true;
            $cache[$sz][$ip] = [
                'is_unbannable' => $is_unbannable,
                'ban_until' => $ban_until,
                'ret' => $ret,
            ];
            return $ret;
        }
    }

    // Read in IP bans - we need to check both DB and .htaccess for completeness (expiring bans will not be in .htaccess)
    $ip_bans = (function_exists('persistent_cache_get') && ($check_caching)) ? persistent_cache_get('IP_BANS') : null;
    if ($ip_bans === null) {
        $tables = ['banned_ip', 'usersubmitban_ip'/*LEGACY*/];
        $ip_bans = null;
        foreach ($tables as $table) {
            $_ip_bans = $GLOBALS['SITE_DB']->query_select_value_if_there($table, 'COUNT(*)', [], '', true);
            if ($_ip_bans === null) {
                continue;
            }

            if ($ip_bans === null) {
                $ip_bans = [];
            }

            $start = 0;
            $max = 100;
            do {
                $rows = $GLOBALS['SITE_DB']->query_select($table, ['*'], [], '', $max, $start);
                foreach ($rows as $row) {
                    $ip_bans[] = $row;
                }

                $start += $max;
            } while (!empty($rows));

            break;
        }
        if ($ip_bans === null) {
            critical_error('DATABASE_FAIL');
        }

        persistent_cache_set('IP_BANS', $ip_bans);
    }

    global $SITE_INFO;
    if ((is_file(get_file_base() . '/.htaccess')) && ((!$force_db) && (((isset($SITE_INFO['known_suexec'])) && ($SITE_INFO['known_suexec'] == '1')) || (cms_is_writable(get_file_base() . '/.htaccess'))))) {
        $bans = [];
        $htaccess_file = cms_file_get_contents_safe(get_file_base() . '/.htaccess', FILE_READ_LOCK);
        $ban_count = preg_match_all('#\n(Require not ip|Require not Ip) (.*)#i', $htaccess_file, $bans); // We define 'ip' and 'Ip' due to Turkish issue
        for ($i = 0; $i < $ban_count; $i++) {
            $ip_bans[$bans[2][$i]] = ['ip' => ip_apache_to_wild($bans[2][$i])];
        }
    }

    // Get IP components, used for wildcard checks
    $ip4 = (strpos($ip, '.') !== false);
    if ($ip4) {
        $ip_parts = explode('.', $ip);
    } else {
        $ip_parts = explode(':', $ip);
    }

    // Check all bans in the database
    $server_ips = null;
    foreach ($ip_bans as $ban) {
        if ((isset($ban['i_ban_until'])) && ($ban['i_ban_until'] < time())) {
            $GLOBALS['SITE_DB']->query('DELETE FROM ' . get_table_prefix() . 'banned_ip WHERE i_ban_until IS NOT NULL AND i_ban_until<' . strval(time()));
            continue;
        }

        if ((($ip4) && (_compare_ip_address($ban['ip'], $ip_parts, '.'))) || ((!$ip4) && (_compare_ip_address($ban['ip'], $ip_parts, ':')))) {
            // Found a ban, which may be a positive or negative ban...

            // Edge case: Do not consider bans for anything that would match the server's own IP or a localhost IP (may only match as a wildcard)
            if ($server_ips === null) {
                $server_ips = get_server_ips();
            }
            foreach ($server_ips as $server_ip) {
                if (compare_ip_address($ban['ip'], $server_ip)) {
                    continue 2;
                }
            }

            // Get result
            if (array_key_exists('i_ban_positive', $ban)) {
                $ret = ($ban['i_ban_positive'] == 1);
                if (!$ret) {
                    $is_unbannable = true;
                }
                $ban_until = $ban['i_ban_until'];
            } else {
                // LEGACY
                $ret = true;
                $ban_until = null;
            }

            $cache[$sz][$ip] = [
                'is_unbannable' => $is_unbannable,
                'ban_until' => $ban_until,
                'ret' => $ret,
            ];
            return $ret;
        }
    }

    // Nothing found
    $ret = $handle_uncertainties ? null : false;
    $cache[$sz][$ip] = [
        'is_unbannable' => $is_unbannable,
        'ban_until' => $ban_until,
        'ret' => $ret,
    ];

    return $ret;
}

/**
 * Convert Apache netmask syntax in IP addresses to simple Composr wildcard syntax.
 *
 * @param  IP $ip The IP address (potentially encoded with netmask syntax)
 * @return string The Composr-style IP wildcard
 */
function ip_apache_to_wild(string $ip) : string
{
    $matches = [];
    if (preg_match('#^(.*)/(\d+)$#', $ip, $matches) == 0) {
        $ip = normalise_ip_address($ip, 4);
        if ($ip == '') {
            return '';
        }

        return $ip;
    }

    $ip_section = $matches[1];
    $range_bits = $matches[2];

    $ipv6 = (strpos($ip, ':') !== false);
    if ($ipv6) {
        $delimiter = ':';
        $bits_per_part = 16;
        $expected_blank_part = '0000';
    } else {
        $delimiter = '.';
        $bits_per_part = 8;
        $expected_blank_part = '0';
    }

    if (($range_bits % $bits_per_part) != 0) {
        return '';
    }

    $parts = explode($delimiter, $ip_section);
    $ip = '';
    $bits_used = 0;
    foreach ($parts as $part) {
        if ($ip != '') {
            $ip .= $delimiter;
        }
        if ($bits_used >= $range_bits) {
            if ($part != $expected_blank_part) {
                return '';
            }
            $ip .= '*';
        } else {
            $ip .= $part;
        }
        $bits_used += $bits_per_part;
    }
    return $ip;
}

/**
 * Log an action.
 *
 * @param  ID_TEXT $type The type of activity just carried out (a language string codename)
 * @param  ?SHORT_TEXT $a The most important parameter of the activity (e.g. D) (null: none)
 * @param  ?SHORT_TEXT $b A secondary (perhaps, human readable) parameter of the activity (e.g. caption) (null: none)
 * @param  boolean $return_id Whether to return an ID to the log entry (forces immediate logging, rather than at script end)
 * @return ?AUTO_LINK Log ID (null: did not save a log)
 */
function log_it(string $type, ?string $a = null, ?string $b = null, bool $return_id = false) : ?int
{
    require_code('global4');

    global $RELATED_WARNING_ID;
    $related_warning_id = $RELATED_WARNING_ID;

    if ($return_id) {
        return _log_it($type, $a, $b, $related_warning_id);
    }

    if (peek_force_immediate_log_it()) {
        _log_it($type, $a, $b, $related_warning_id);
        return null;
    }

    cms_register_shutdown_function_safe(function () use ($type, $a, $b, $related_warning_id) {
        return _log_it($type, $a, $b, $related_warning_id);
    });

    return null;
}

/**
 * Define whether log_it should forcefully execute immediately instead of trying to register itself in a shutdown function.
 *
 * @param  boolean $value Whether log_it should forcefully log immediately
 */
function push_force_immediate_log_it(bool $value)
{
    global $FORCE_IMMEDIATE_LOG_IT;
    $FORCE_IMMEDIATE_LOG_IT[] = $value;
}

/**
 * Remove a push_force_immediate_log_it value off of the end of our chain.
 */
function pop_force_immediate_log_it()
{
    global $FORCE_IMMEDIATE_LOG_IT;
    array_pop($FORCE_IMMEDIATE_LOG_IT);
}

/**
 * Check whether log_it should be forcefully running immediately instead of registering as a shutdown function.
 *
 * @return boolean Whether log_it should run immediately
 */
function peek_force_immediate_log_it() : bool
{
    global $FORCE_IMMEDIATE_LOG_IT;
    return end($FORCE_IMMEDIATE_LOG_IT);
}

/**
 * Escape a string to fit within PHP double quotes.
 *
 * @param  string $in String in
 * @return string Resultant string
 */
function php_addslashes(string $in) : string
{
    global $PHP_REP_FROM, $PHP_REP_TO;
    return str_replace($PHP_REP_FROM, $PHP_REP_TO, $in);
}

/**
 * Update the member tracker for the currently viewing user.
 *
 * @param  ID_TEXT $page The page
 * @param  ID_TEXT $type The type
 * @param  ID_TEXT $id The ID
 */
function member_tracking_update(string $page, string $type, string $id)
{
    if (get_value('disable_member_tracking') === '1') {
        return;
    }

    if (mt_rand(0, 100) == 1) {
        cms_register_shutdown_function_safe(function () {
            if (!$GLOBALS['SITE_DB']->table_is_locked('member_tracking')) {
                $GLOBALS['SITE_DB']->query('DELETE FROM ' . get_table_prefix() . 'member_tracking WHERE mt_time<' . strval(time() - 60 * intval(get_option('users_online_time'))));
            }
        });
    }

    cms_register_shutdown_function_safe(function () use ($page, $type, $id) {
        $GLOBALS['SITE_DB']->query_insert_or_replace(
            'member_tracking',
            [
                'mt_cache_username' => $GLOBALS['FORUM_DRIVER']->get_username(get_member(), true),
                'mt_time' => time(),
            ],
            [
                'mt_member_id' => get_member(),
                'mt_page' => $page,
                'mt_type' => $type,
                'mt_id' => $id,
            ]
        );
    });
}

/**
 * Find whether the current user is invisible.
 *
 * @return boolean Whether the current user is invisible
 */
function is_invisible() : bool
{
    global $SESSION_CACHE;
    $s = get_session_id();
    return (isset($SESSION_CACHE[$s])) && ($SESSION_CACHE[$s]['session_invisible'] == 1);
}

/**
 * Find the session tracking codes, most relevant first.
 *
 * @param  ?EMAIL $email_address A new user's e-mail address, to be used to track an inviter/recommender (null: N/A)
 * @return array The tracking codes
 */
function find_session_tracking_codes(?string $email_address = null) : array
{
    $tracking_codes = [];

    $tracking_code = get_param_string('_t', '');
    if ($tracking_code != '') {
        $tracking_codes = array_merge($tracking_codes, explode(',', $tracking_code));
    }

    if (addon_installed('stats')) {
        static $tracking_code_rows = null;
        if ($tracking_code_rows === null) {
            $tracking_code_rows = $GLOBALS['SITE_DB']->query_select('stats', ['tracking_code', 'date_and_time'], ['session_id' => get_session_id()], ' AND ' . db_string_not_equal_to('tracking_code', '') . ' ORDER BY date_and_time DESC');
        }
        foreach ($tracking_code_rows as $tracking_code_row) {
            $tracking_codes = array_merge($tracking_codes, explode(',', $tracking_code_row['tracking_code']));
        }
    }

    if ((addon_installed('recommend')) && (!empty($email_address))) {
        // This is not strictly a tracking code (it won't come up in the stats system for example), but we roll it into this function for simplicity
        static $inviter = false;
        if ($inviter === false) {
            $_inviter = $GLOBALS['FORUM_DB']->query_select('f_invites', ['i_inviter', 'i_time'], ['i_email_address' => $email_address], 'ORDER BY i_time DESC', 1);
            $inviter = array_key_exists(0, $_inviter) ? $_inviter[0]['i_inviter'] : null;
        }
        if ($inviter !== null) {
            $tracking_codes[] = strval($inviter);
        }
    }

    return array_unique($tracking_codes);
}

/**
 * Get the number of users on the site in the last 5 minutes. The function also maintains the statistic via the sessions table.
 *
 * @return integer The number of users on the site
 */
function get_num_users_site() : int
{
    if (get_value('disable_user_online_counting') === '1') {
        return 1;
    }

    global $NUM_USERS_SITE_CACHE, $PEAK_USERS_EVER_CACHE, $PEAK_USERS_WEEK_CACHE;
    $users_online_time_seconds = 60 * intval(get_option('users_online_time'));
    $NUM_USERS_SITE_CACHE = get_value_newer_than('users_online', time() - $users_online_time_seconds / 2); /* Refreshes half way through the user online time, to approximate accuracy */
    if ($NUM_USERS_SITE_CACHE === null) {
        $NUM_USERS_SITE_CACHE = get_value('users_online');
        $count = 0;
        require_code('users2');
        get_users_online(false, null, $count);
        $NUM_USERS_SITE_CACHE = strval($count);
        if (!$GLOBALS['SITE_DB']->table_is_locked('values')) {
            set_value('users_online', $NUM_USERS_SITE_CACHE);
        }
    }
    if (addon_installed('stats')) {
        // Store a peak record if there is one
        $PEAK_USERS_EVER_CACHE = get_value('user_peak');
        if (($PEAK_USERS_EVER_CACHE === null) || ($PEAK_USERS_EVER_CACHE == '')) {
            $_peak_users_user = $GLOBALS['SITE_DB']->query_select_value_if_there('usersonline_track', 'MAX(peak)');
            $PEAK_USERS_EVER_CACHE = ($_peak_users_user === null) ? $NUM_USERS_SITE_CACHE : strval($_peak_users_user);
            if (!$GLOBALS['SITE_DB']->table_is_locked('values')) {
                set_value('user_peak', $PEAK_USERS_EVER_CACHE);
            }
        }
        if (intval($NUM_USERS_SITE_CACHE) > intval($PEAK_USERS_EVER_CACHE)) {
            // New record
            $GLOBALS['SITE_DB']->query_insert_or_replace('usersonline_track', ['peak' => intval($NUM_USERS_SITE_CACHE)], ['date_and_time' => time()]);
            if (!$GLOBALS['SITE_DB']->table_is_locked('values')) {
                set_value('user_peak', $NUM_USERS_SITE_CACHE);
            }
        }

        // Store a 7-day-cycle peak record if we've made one
        $PEAK_USERS_WEEK_CACHE = get_value_newer_than('user_peak_week', time() - $users_online_time_seconds / 2);
        $store_anyway = false;
        if (($PEAK_USERS_WEEK_CACHE === null) || ($PEAK_USERS_WEEK_CACHE == '')) {
            $store_anyway = true;
        }
        if ((intval($NUM_USERS_SITE_CACHE) > intval($PEAK_USERS_WEEK_CACHE)) || ($store_anyway)) {
            $PEAK_USERS_WEEK_CACHE = $NUM_USERS_SITE_CACHE;

            // But also delete anything else in the last 7 days that was less than the new weekly peak record, to keep the stats clean (we only want 7 day peaks to be stored)
            $GLOBALS['SITE_DB']->query('DELETE FROM ' . get_table_prefix() . 'usersonline_track WHERE date_and_time>' . strval(time() - 60 * 60 * 24 * 7) . ' AND peak<=' . $PEAK_USERS_WEEK_CACHE);

            // Set record for week
            set_value('user_peak_week', $PEAK_USERS_WEEK_CACHE);
            $GLOBALS['SITE_DB']->query_insert('usersonline_track', ['date_and_time' => time(), 'peak' => intval($PEAK_USERS_WEEK_CACHE)], false, true); // errors suppressed in case of race condition
        }
    }
    return intval($NUM_USERS_SITE_CACHE);
}

/**
 * Get the largest amount of users ever to be on the site at the same time.
 *
 * @return integer The number of peak users
 */
function get_num_users_peak() : int
{
    global $PEAK_USERS_EVER_CACHE;
    return intval($PEAK_USERS_EVER_CACHE);
}

/**
 * Escape certain special characters in the provided string, so that it can be embedded as text within HTML.
 *
 * @param  string $string The input string
 * @return string The escaped string
 */
function escape_html(string $string) : string
{
    /*if ($GLOBALS['XSS_DETECT']) {  Useful for debugging
        if (ocp_is_escaped($string)) {
            @var_dump(debug_backtrace());
            @exit('String double-escaped');
        }
    }*/

    global $XSS_DETECT, $ESCAPE_HTML_OUTPUT, $DECLARATIONS_STATE;

    $ret = @htmlspecialchars($string, ENT_QUOTES | ENT_SUBSTITUTE);

    if (defined('I_UNDERSTAND_XSS') && !$DECLARATIONS_STATE[I_UNDERSTAND_XSS]) {
        $ESCAPE_HTML_OUTPUT[$ret] = true;
    }

    if ($XSS_DETECT) {
        ocp_mark_as_escaped($ret);
    }

    return $ret;
}

/**
 * Escape certain special characters in the provided string, so that it can be embedded as HTML within Comcode.
 *
 * @param  mixed $string The input string
 * @return mixed The escaped string
 */
function escape_html_in_comcode($string)
{
    $is_tempcode = is_object($string);
    if ($is_tempcode) {
        $string = $string->evaluate();
    }
    $ret = preg_replace('#\[(/html)#i', '&#91;$1', $string);
    if ($is_tempcode) {
        $ret = make_string_tempcode($ret);
    }
    return $ret;
}

/**
 * See's if the current browser matches some special property code. Assumes users are keeping up on newish browsers (as defined in tut_browsers).
 *
 * @param  string $code The property code
 * @set android ios wysiwyg windows mac linux odd_os mobile ie gecko safari odd_browser chrome bot simplified_attachments_ui itunes
 * @param  ?string $comcode Comcode that might be WYSIWYG edited; used to determine whether WYSIWYG may load when we'd prefer it to not do so (null: none)
 * @return boolean Whether there is a match
 */
function browser_matches(string $code, ?string $comcode = null) : bool
{
    static $browser_matches_cache = [];
    if (isset($browser_matches_cache[$code])) {
        return $browser_matches_cache[$code];
    }

    $browser = cms_strtolower_ascii(get_browser_string());
    $os = cms_strtolower_ascii(get_os_string());
    $is_safari = strpos($browser, 'applewebkit') !== false;
    $is_chrome = strpos($browser, 'chrome/') !== false;
    $is_gecko = (strpos($browser, 'gecko') !== false) && !$is_safari;
    $is_ie = ((strpos($browser, 'msie') !== false) || (strpos($browser, 'trident') !== false) || (strpos($browser, 'edge/') !== false));

    switch ($code) {
        case 'simplified_attachments_ui':
            $browser_matches_cache[$code] = (get_option('simplified_attachments_ui') == '1') && (get_option('complex_uploader') == '1');
            return $browser_matches_cache[$code];
        case 'itunes':
            $browser_matches_cache[$code] = (get_param_integer('itunes', 0) == 1) || (strpos($browser, 'itunes') !== false);
            return $browser_matches_cache[$code];
        case 'bot':
            $browser_matches_cache[$code] = (get_bot_type() !== null);
            return $browser_matches_cache[$code];
        case 'android':
            $browser_matches_cache[$code] = strpos($browser, 'android') !== false;
            return $browser_matches_cache[$code];
        case 'ios':
            $browser_matches_cache[$code] = strpos($browser, 'iphone') !== false || strpos($browser, 'ipad') !== false;
            return $browser_matches_cache[$code];
        case 'wysiwyg':
            if ((get_option('wysiwyg') == '0') || ((is_mobile()) && (($comcode === null) || (strpos($comcode, 'html]') === false)))) {
                $browser_matches_cache[$code] = false;
                return false;
            }
            $browser_matches_cache[$code] = (strpos($browser, 'android') === false); // Using CKEditor, which does not yet support Android
            return $browser_matches_cache[$code];
        case 'windows':
            $browser_matches_cache[$code] = (strpos($os, 'windows') !== false) || (strpos($os, 'win32') !== false);
            return $browser_matches_cache[$code];
        case 'mac':
            $browser_matches_cache[$code] = strpos($os, 'mac') !== false;
            return $browser_matches_cache[$code];
        case 'linux':
            $browser_matches_cache[$code] = strpos($os, 'linux') !== false;
            return $browser_matches_cache[$code];
        case 'odd_os':
            $browser_matches_cache[$code] = (strpos($os, 'windows') === false) && (strpos($os, 'mac') === false) && (strpos($os, 'linux') === false);
            return $browser_matches_cache[$code];
        case 'mobile':
            $browser_matches_cache[$code] = is_mobile();
            return $browser_matches_cache[$code];
        case 'ie':
            $browser_matches_cache[$code] = $is_ie;
            return $browser_matches_cache[$code];
        case 'chrome':
            $browser_matches_cache[$code] = $is_chrome;
            return $browser_matches_cache[$code];
        case 'gecko':
            $browser_matches_cache[$code] = $is_gecko;
            return $browser_matches_cache[$code];
        case 'safari':
            $browser_matches_cache[$code] = $is_safari;
            return $browser_matches_cache[$code];
        case 'odd_browser':
            $browser_matches_cache[$code] = !$is_safari && !$is_gecko && !$is_ie;
            return $browser_matches_cache[$code];
    }

    // Should never get here
    return false;
}

/**
 * Look at the user's browser, and decide if they are viewing on a mobile device or not.
 * A mobile device can generally be considered a touchscreen device with a screen smaller than a desktop/laptop.
 * There is no perfect delineation.
 *
 * @param  ?string $user_agent The user agent (null: get from environment, current user's browser)
 * @param  boolean $include_tablets Whether to include tablets (which generally would be served the desktop version of a website)
 * @param  boolean $truth Whether to always tell the truth (even if the current page does not have mobile support)
 * @return boolean Whether the user is using a mobile device
 */
function is_mobile(?string $user_agent = null, bool $include_tablets = false, bool $truth = false) : bool
{
    static $is_mobile_cache = [];

    if (isset($is_mobile_cache[$user_agent][$include_tablets][$truth])) {
        return $is_mobile_cache[$user_agent][$include_tablets][$truth];
    }

    if ((!function_exists('get_option')) || (get_theme_option('mobile_support') == '0')) {
        if (function_exists('get_option')) {
            $is_mobile_cache[null][false][false] = false;
            $is_mobile_cache[null][false][true] = false;
            $is_mobile_cache[null][true][false] = false;
            $is_mobile_cache[null][true][true] = false;
            $is_mobile_cache[$user_agent][false][false] = false;
            $is_mobile_cache[$user_agent][false][true] = false;
            $is_mobile_cache[$user_agent][true][false] = false;
            $is_mobile_cache[$user_agent][true][true] = false;
        }
        return false;
    }

    if ($user_agent === null) {
        $user_agent = $_SERVER['HTTP_USER_AGENT'];
        $user_agent_given = false;
    } else {
        $user_agent_given = true;
    }

    // If we are listing mobile pages, check to see if the current page was not listed
    global $SITE_INFO;
    if (((!isset($SITE_INFO['assume_full_mobile_support'])) || ($SITE_INFO['assume_full_mobile_support'] != '1')) && (isset($GLOBALS['FORUM_DRIVER'])) && (!$truth) && (running_script('index')) && (($theme = $GLOBALS['FORUM_DRIVER']->get_theme()) != 'default')) {
        $mobile_pages = get_theme_option('mobile_pages');
        if ($mobile_pages != '') {
            $page = get_param_string('page', ''); // We intentionally do not use get_page_name, as that requires URL Monikers to work, which are not available early in boot (as needed by static cache)

            if (
                (substr($mobile_pages, 0, 1) == '#' && substr($mobile_pages, -1) == '#' && preg_match($mobile_pages, get_zone_name() . ':' . $page) == 0) ||
                ((substr($mobile_pages, 0, 1) != '#' || substr($mobile_pages, -1) != '#') && preg_match('#(^|,)\s*' . str_replace('#', '\#', preg_quote($page)) . '\s*(,|$)#', $mobile_pages) == 0 && preg_match('#(^|,)\s*' . str_replace('#', '\#', preg_quote(get_zone_name() . ':' . $page)) . '\s*(,|$)#', $mobile_pages) == 0)
            ) {
                $is_mobile_cache[null][false][false] = false;
                $is_mobile_cache[null][true][false] = false;
                $is_mobile_cache[$user_agent][false][false] = false;
                $is_mobile_cache[$user_agent][true][false] = false;
                return false;
            }
        }
    }

    // If specified by URL
    if (!$user_agent_given && !$truth) {
        $val = get_param_integer('keep_mobile', null);
        if ($val !== null) {
            $result = ($val == 1);
            if (isset($GLOBALS['FORUM_DRIVER'])) {
                $is_mobile_cache[null][false][false] = $result;
                $is_mobile_cache[null][true][false] = $result;
            }
            return $result;
        }
    }

    // The set of browsers (also change in static_cache.php)
    $mobile_agents = [
        // Implication by technology claims
        'WML',
        'WAP',
        'Wap',
        'MIDP', // Mobile Information Device Profile

        // Generics
        'Mobile',
        'Smartphone',

        // Well known/important browsers/brands
        'Mobile Safari', // Usually Android
        'Android',
        'iPhone',
        'iPod',
        'Opera Mobi',
        'Opera Mini',
        'BlackBerry',
        'Windows Phone',
        'nook browser', // Barnes and Noble
    ];
    $tablets = [
        'iPad',
        'tablet',
        'kindle',
        'silk',
    ];

    if (((!isset($SITE_INFO['no_extra_mobiles'])) || ($SITE_INFO['no_extra_mobiles'] != '1')) && (is_file(get_file_base() . '/text_custom/mobile_devices.txt'))) {
        require_code('files');
        $mobile_devices = cms_parse_ini_file_fast((get_file_base() . '/text_custom/mobile_devices.txt'));
        foreach ($mobile_devices as $key => $val) {
            if ($val == 1) {
                $mobile_agents[] = $key;
            } else {
                $tablets[] = $key;
            }
        }
    }

    // The regular test
    $is_tablet = (preg_match('/(' . implode('|', $tablets) . ')/i', $user_agent) != 0) || (strpos($user_agent, 'Android') !== false) && (strpos($user_agent, 'Mobile') === false);
    $result = (preg_match('/(' . implode('|', $mobile_agents) . ')/i', $user_agent) != 0) && (($include_tablets) || (!$is_tablet));
    if (isset($GLOBALS['FORUM_DRIVER'])) {
        $is_mobile_cache[$user_agent][$include_tablets][$truth] = $result;
    }

    return $result;
}

/**
 * Get the name of a webcrawler bot, or null if no bot detected.
 *
 * @param  ?string $agent User agent (null: read from environment)
 * @return ?string Webcrawling bot name (null: not a bot)
 */
function get_bot_type(?string $agent = null) : ?string
{
    $agent_given = ($agent !== null);

    if (!$agent_given) {
        global $BOT_TYPE_CACHE;
        if ($BOT_TYPE_CACHE !== false) {
            return $BOT_TYPE_CACHE;
        }

        $agent = $_SERVER['HTTP_USER_AGENT'];
    }

    if (strpos($agent, 'WebKit') !== false || strpos($agent, 'Trident') !== false || strpos($agent, 'MSIE') !== false || strpos($agent, 'Firefox') !== false || strpos($agent, 'Opera') !== false) {
        if (strpos($agent, 'bot') === false) {
            // Quick exit path
            if (!$agent_given) {
                $BOT_TYPE_CACHE = null;
            }
            return null;
        }
    }

    $agent = cms_strtolower_ascii($agent);

    global $BOT_MAP_CACHE, $SITE_INFO;
    if ($BOT_MAP_CACHE === null) {
        if (((!isset($SITE_INFO['no_extra_bots'])) || ($SITE_INFO['no_extra_bots'] != '1')) && (is_file(get_file_base() . '/text_custom/bots.txt'))) {
            require_code('files');
            $BOT_MAP_CACHE = cms_parse_ini_file_fast(get_file_base() . '/text_custom/bots.txt');
        } else {
            $BOT_MAP_CACHE = [
                'zyborg' => 'Looksmart',
                'googlebot' => 'Google',
                'mediapartners-google' => 'Google Adsense',
                'teoma' => 'Teoma',
                'jeeves' => 'Ask Jeeves',
                'ultraseek' => 'Infoseek',
                'ia_archiver' => 'Alexa/Archive.org',
                'msnbot' => 'Bing',
                'bingbot' => 'Bing',
                'mantraagent' => 'LookSmart',
                'wisenutbot' => 'Looksmart',
                'paros' => 'Paros',
                'sqworm' => 'Aol.com',
                'baidu' => 'Baidu',
                'facebookexternalhit' => 'Facebook',
                'yandex' => 'Yandex',
                'daum' => 'Daum',
                'ahrefsbot' => 'Ahrefs',
                'mj12bot' => 'Majestic-12',
                'blexbot' => 'webmeup',
                'duckduckbot' => 'DuckDuckGo',
            ];
        }
    }
    foreach ($BOT_MAP_CACHE as $id => $name) {
        if ($name == '') {
            continue;
        }
        if (strpos($agent, $id) !== false) {
            if (!$agent_given) {
                $BOT_TYPE_CACHE = $name;
            }

            return $name;
        }
    }

    if ((strpos($agent, 'bot') !== false) || (strpos($agent, 'spider') !== false)) {
        $to_a = strpos($agent, ' ');
        if ($to_a === false) {
            $to_a = strlen($agent);
        }
        $to_b = strpos($agent, '/');
        if ($to_b === false) {
            $to_b = strlen($agent);
        }

        $name = substr($agent, 0, min($to_a, $to_b));

        if (!$agent_given) {
            $BOT_TYPE_CACHE = $name;
        }

        return $name;
    }

    if (!$agent_given) {
        $BOT_TYPE_CACHE = null;
    }

    return null;
}

/**
 * Determine whether the user's browser supports cookies or not.
 * Unfortunately this function will only return true once a user has been to the site more than once... Composr will set a cookie, and if it perseveres, that indicates cookies work.
 *
 * @return boolean Whether the user has definitely got cookies
 */
function has_cookies() : bool // Will fail on users first visit, but then will catch on
{
    static $has_cookies_cache = null;
    if ($has_cookies_cache !== null) {
        return $has_cookies_cache;
    }

    /*if (($GLOBALS['DEV_MODE']) && (get_param_integer('keep_debug_has_cookies', 0) == 0) && (!running_script('commandr')))   We know this works by now, was tested for years. Causes annoyance when developing
    {
        $_COOKIE = [];
        return false;
    }*/

    if (isset($_COOKIE['has_cookies'])) {
        $has_cookies_cache = true;
        return true;
    }
    if (running_script('index')) {
        cms_setcookie('has_cookies', '1', false, false);
    }
    $has_cookies_cache = false;
    return false;
}

/**
 * Determine whether the user's browser supports JavaScript or not.
 * Unfortunately this function will only return true once a user has been to the site more than once... JavaScript will set a cookie, indicating it works.
 *
 * @return boolean Whether the user has definitely got JavaScript
 */
function has_js() : bool
{
    if (!function_exists('get_option')) {
        return true;
    }
    if (get_option('detect_javascript') == '0') {
        return true;
    }
    if (get_param_integer('keep_has_js', 0) == 1) {
        return true;
    }
    if (get_param_integer('keep_has_js', null) === 0) {
        return false;
    }
    return (isset($_COOKIE['js_on'])) && ($_COOKIE['js_on'] == '1');
}

/**
 * Get a word-filtered version of the specified text.
 *
 * @param  string $text Text to filter
 * @return string Filtered version of the input text
 */
function wordfilter_text(string $text) : string
{
    if (!addon_installed('wordfilter')) {
        return $text;
    }
    if ($GLOBALS['IN_MINIKERNEL_VERSION']) {
        return $text;
    }

    require_code('wordfilter');
    return check_wordfilter($text, null, true);
}

/**
 * Assign this to explicitly declare that a variable may be of mixed type, and initialise to null.
 *
 * @return ?mixed Of mixed type (null: default)
 */
function mixed()
{
    return null;
}

/**
 * Get meta information for specified resource.
 *
 * @param  ID_TEXT $type The type of resource (e.g. download)
 * @param  ID_TEXT $id The ID of the resource
 * @return array A tuple: The first element is the meta keyword string for the specified resource, the second is the meta description string, the third is meta keywords that are for syndication only
 */
function seo_meta_get_for(string $type, string $id) : array
{
    $cache = function_exists('persistent_cache_get') ? persistent_cache_get(['seo', $type, $id]) : null;
    if ($cache !== null) {
        return $cache;
    }

    $where = ['meta_for_type' => $type, 'meta_for_id' => $id];

    $cache = ['', '', ''];

    $rows = $GLOBALS['SITE_DB']->query_select('seo_meta_keywords', ['meta_keyword', 'id', 'sort_order'], $where, 'ORDER BY sort_order');
    foreach ($rows as $row) {
        if ($cache[0] != '') {
            $cache[0] .= ',';
        }
        $keyword = get_translated_text($row['meta_keyword']);
        if (substr($keyword, 0, 1) == '#') {
            $keyword = substr($keyword, 1);
            $cache[2] .= $keyword;
        }
        $cache[0] .= $keyword;
    }

    $rows = $GLOBALS['SITE_DB']->query_select('seo_meta', ['meta_description'], $where, '', 1);
    if (array_key_exists(0, $rows)) {
        $cache[1] = get_translated_text($rows[0]['meta_description']);
    }

    persistent_cache_set(['seo', $type, $id], $cache);

    return $cache;
}

/**
 * Load the specified resource's meta information into the system for use on this page.
 * Also, if the title is specified then this is used for the page title.
 *
 * @sets_output_state
 *
 * @param  ID_TEXT $type The type of resource (e.g. download)
 * @param  ID_TEXT $id The ID of the resource
 * @param  ?string $title The page-specific title to use, in Comcode or plain-text format with possible HTML entities included [Comcode will later be stripped] (null: none)
 */
function seo_meta_load_for(string $type, string $id, ?string $title = null)
{
    if (!$GLOBALS['IS_VIRTUALISED_REQUEST']) {
        $result = seo_meta_get_for($type, $id);
        global $SEO_KEYWORDS, $SEO_DESCRIPTION, $SHORT_TITLE;
        if ($result[0] != '') {
            $SEO_KEYWORDS = array_map('trim', explode(',', trim($result[0], ',')));
        }
        if ($result[1] != '') {
            $SEO_DESCRIPTION = $result[1];
        }
        if ($title !== null) {
            set_short_title(str_replace('&ndash;', '-', str_replace('&copy;', '(c)', str_replace('&#039;', '\'', $title))));
        }
    }
    // Otherwise don't bother (this is an optimisation)
}

/**
 * Get Tempcode for tags, based on loaded up from SEO keywords (seo_meta_load_for).
 *
 * @param  ?ID_TEXT $limit_to The search code for this tag content (e.g. downloads) (null: there is none)
 * @param  ?array $the_tags Explicitly pass a list of tags instead (null: use loaded ones)
 * @return Tempcode Loaded tag output (or blank if there are none)
 */
function get_loaded_tags(?string $limit_to = null, ?array $the_tags = null) : object
{
    if (get_value('disable_tags') === '1') {
        return new Tempcode();
    }
    if (!addon_installed('search')) {
        return new Tempcode();
    }

    if ($the_tags === null) {
        global $SEO_KEYWORDS;
        $the_tags = $SEO_KEYWORDS;
    }

    $tags = [];
    if ($the_tags !== null) {
        $search_limiter_no = ['all_defaults' => '1'];
        if ($limit_to !== null) {
            $search_limiter_no['search_' . $limit_to] = '1';
            $search_limiter_no['all_defaults'] = '0';
        }

        if ($limit_to !== null) {
            $search_limiter_yes = [];
            $search_limiter_yes['search_' . $limit_to] = '1';
            $search_limiter_yes['all_defaults'] = '0';
        } else {
            $search_limiter_yes = $search_limiter_no;
        }

        foreach ($the_tags as $tag) {
            $tag = trim($tag);
            if ($tag == '') {
                continue;
            }

            $tags[] = [
                'TAG' => $tag,
                'LINK_LIMITEDSCOPE' => build_url(['page' => 'search', 'type' => 'results', 'content' => '"' . $tag . '"', 'only_search_meta' => '1'] + $search_limiter_yes, get_module_zone('search')),
                'LINK_FULLSCOPE' => build_url(['page' => 'search', 'type' => 'results', 'content' => '"' . $tag . '"', 'only_search_meta' => '1'] + $search_limiter_no, get_module_zone('search')),
            ];
        }
    }

    return do_template('TAGS', ['_GUID' => '2cd542a245bc7d1c3f10e858e8fc5159', 'TAGS' => $tags, 'TYPE' => ($limit_to === null) ? '' : $limit_to]);
}

/**
 * Get the default page for a zone.
 *
 * @param  ID_TEXT $zone_name Zone name
 * @param  boolean $zone_missing Whether the zone is missing (returned by reference)
 * @return ID_TEXT Default page
 */
function get_zone_default_page(string $zone_name, bool &$zone_missing = false) : string
{
    $zone_missing = false;

    if ($zone_name == '_SELF') {
        $zone_name = get_zone_name();
    }

    /*$p_test = function_exists('persistent_cache_get') ? persistent_cache_get(['ZONE', $zone_name]) : null;       Better to get from ALL_ZONES_TITLED, less cache volume
    if ($p_test !== null) {
        return $p_test['zone_default_page'];
    }*/

    global $ZONE;
    if (($ZONE !== null) && ($ZONE['zone_name'] == $zone_name) && ($ZONE['zone_default_page'] !== null)) {
        return $ZONE['zone_default_page'];
    }

    global $ZONE_DEFAULT_PAGES_CACHE;
    if (!isset($ZONE_DEFAULT_PAGES_CACHE[$zone_name])) {
        $_zone_default_page = null;
        if (function_exists('persistent_cache_get')) {
            $temp = persistent_cache_get('ALL_ZONES_TITLED');
            if ($temp !== null) {
                $_zone_default_page = [];
                foreach ($temp as $_temp) {
                    list($_zone_name, , $zone_default_page) = $_temp;
                    $_zone_default_page[] = ['zone_name' => $_zone_name, 'zone_default_page' => $zone_default_page];
                }
            }
        }
        if ($_zone_default_page === null) {
            $_zone_default_page = $GLOBALS['SITE_DB']->query_select('zones', ['zone_name', 'zone_default_page', 'zone_title'], []/*Load multiple so we can cache for performance ['zone_name' => $zone_name]*/, 'ORDER BY zone_title', 50/*reasonable limit; zone_title is sequential for default zones*/);
        }
        foreach ($_zone_default_page as $zone_row) {
            $ZONE_DEFAULT_PAGES_CACHE[$zone_row['zone_name']] = $zone_row['zone_default_page'];
        }
        if (!isset($ZONE_DEFAULT_PAGES_CACHE[$zone_name])) {
            $zone_missing = true;
            $ZONE_DEFAULT_PAGES_CACHE[$zone_name] = DEFAULT_ZONE_PAGE_NAME;
        }
    }

    return $ZONE_DEFAULT_PAGES_CACHE[$zone_name];
}

/**
 * Turn a boring codename, into a "pretty" title.
 *
 * @param  ID_TEXT $boring The codename
 * @return string The title
 */
function titleify(string $boring) : string
{
    $ret = $boring;

    if (strpos($ret, '/') !== false || strpos($ret, '\\') !== false) {
        $ret = preg_replace('#([/\\\\])#', ' ${1} ', $ret);
    }

    $ret = cms_ucwords_ascii(trim(str_replace('_', ' ', $ret)));

    $acronyms = [
        'CMS',
        'CPF',
        'CSV',
        'CNS',
        'URL',
        'ID',
        'UI',
        'HTML',
        'MSN',
        'LDAP',
        'SMS',
        'SSL',
        'XML',
        'CSS',
        'SEO',
        'JavaScript',
        'TTL',
    ];
    foreach ($acronyms as $acronym) {
        if (stripos($ret, $acronym) !== false) {
            $ret = cms_preg_replace_safe('#(^|\s)' . preg_quote($acronym, '#') . '(\s|$)#i', '$1' . $acronym . '$2', $ret);
        }
    }

    if (strpos($ret, 'Ecommerce') !== false) {
        $ret = str_replace('Ecommerce', addon_installed('ecommerce') ? do_lang('ecommerce:ECOMMERCE') : 'eCommerce', $ret);
    }
    if (strpos($ret, 'Cpfs') !== false) {
        $ret = str_replace('Cpfs', do_lang('cns:CUSTOM_PROFILE_FIELDS'), $ret);
    }
    if (strpos($ret, 'Captcha') !== false) {
        $ret = str_replace('Captcha', addon_installed('captcha') ? do_lang('captcha:CAPTCHA') : 'CAPTCHA', $ret);
    }
    $ret = str_replace('Adminzone', do_lang('ADMIN_ZONE'), $ret);
    $ret = str_replace('Emails', do_lang('EMAILS'), $ret);
    $ret = str_replace('Phpinfo', 'PHP-Info', $ret);
    $ret = str_replace('CNS', 'Conversr', $ret);
    if (strpos($ret, 'Default Set') !== false) {
        $ret = str_replace('Default Set/cartoons', do_lang('cns:AVATARS_CARTOONS'), $ret);
        $ret = str_replace('Default Set/thematic', do_lang('cns:AVATARS_THEMATIC'), $ret);
        $ret = str_replace('Default Set', do_lang('cns:AVATARS_MISC'), $ret);
    }

    if ($GLOBALS['XSS_DETECT'] && ocp_is_escaped($boring)) {
        ocp_mark_as_escaped($ret);
    }

    return $ret;
}

/**
 * Propagate Filtercode through links.
 *
 * @param  ID_TEXT $prefix Prefix for main filter environment variable
 * @return array Extra URL mappings
 */
function propagate_filtercode(string $prefix = '') : array
{
    $active_filter = either_param_string(($prefix == '') ? 'active_filter' : ($prefix . '_active_filter'), '', INPUT_FILTER_NONE);
    $map = [];
    if ($active_filter != '') {
        $map['active_filter'] = $active_filter;
        foreach (array_keys($_GET + $_POST) as $key) {
            if (substr($key, 0, 7) == 'filter_') {
                $map[$key] = either_param_string($key, '', INPUT_FILTER_NONE);
            }
        }
    }
    return $map;
}

/**
 * Propagate Filtercode through page-links.
 *
 * @return string Extra page-link mappings
 */
function propagate_filtercode_page_link() : string
{
    $map = propagate_filtercode();
    $_map = '';
    foreach ($map as $key => $val) {
        $_map .= ':' . $key . '=' . urlencode($val);
    }
    return $_map;
}

/**
 * Make some text fractionally editable (i.e. inline editable).
 *
 * @param  ID_TEXT $content_type Content type
 * @param  mixed $id Content ID
 * @param  mixed $title Content title (either unescaped string, or Compiled Comcode [i.e. Tempcode])
 * @return Tempcode Inline editable HTML to put into output
 */
function make_fractionable_editable(string $content_type, $id, $title) : object
{
    require_code('content');
    $ob = get_content_object($content_type);
    $info = $ob->info();

    $title_field = array_key_exists('title_field_post', $info) ? $info['title_field_post'] : $info['title_field'];
    if (is_array($title_field)) {
        return $title;
    }

    $parameters = [
        is_object($title) ? $title->evaluate() : $title,
        array_key_exists('edit_page_link_field', $info) ? $info['edit_page_link_field'] : preg_replace('#^\w\w?_#', '', $title_field),
        array_key_exists('edit_page_link_pattern_post', $info) ? str_replace('_WILD', is_integer($id) ? strval($id) : $id, $info['edit_page_link_pattern_post']) : preg_replace('#:_(.*)#', ':__${1}', str_replace('_WILD', is_integer($id) ? strval($id) : $id, $info['edit_page_link_pattern'])),
        (array_key_exists('title_field_supports_comcode', $info) && $info['title_field_supports_comcode']) ? '1' : '0',
    ];
    return directive_tempcode('FRACTIONAL_EDITABLE', is_object($title) ? $title : escape_html($title), $parameters);
}

/**
 * Find whether a fractional edit is underway.
 *
 * @return boolean Whether a fractional edit is underway
 */
function fractional_edit() : bool
{
    return post_param_integer('fractional_edit', 0) == 1;
}

/**
 * Convert some HTML to plain text.
 *
 * @param  string $in HTML
 * @return string Plain text
 */
function strip_html(string $in) : string
{
    if ((strpos($in, '<') === false) && (strpos($in, '&') === false)) {
        return $in; // Optimisation
    }

    $text = $in;

    // Normalise line breaks
    $text = cms_preg_replace_safe('#\s+#', ' ', $text);
    $text = cms_preg_replace_safe('#(<br(\s[^<>]*)?' . '>)#i', '$1' . "\n", $text);

    // Special stuff to strip
    $search = [
        '#<script[^>]*?' . '>.*?</script>#si',  // Strip out JavaScript
        '#<style[^>]*?' . '>.*?</style>#siU',   // Strip style tags properly
        '#<![\s\S]*?--[ \t\n\r]*>#',            // Strip multi-line comments including CDATA
    ];
    $text = preg_replace($search, '', $text);

    // ASCII conversion
    if (get_charset() != 'utf-8') {
        $text = str_replace(['&ndash;', '&mdash;', '&hellip;', '&middot;', '&ldquo;', '&rdquo;', '&lsquo;', '&rsquo;'], ['-', '-', '...', '|', '"', '"', "'", "'"], $text);
    }

    require_code('webstandards');
    global $TAGS_BLOCK;
    $_block_tags = '(' . implode('|', array_keys($TAGS_BLOCK)) . ')';

    // Remove leading/trailing space from block tags
    $text = cms_preg_replace_safe('#\s*(<' . $_block_tags . '(\s[^<>]*)?' . '>)#i', '$1', $text);
    $text = cms_preg_replace_safe('#(</' . $_block_tags . '>)\s*#i', '$1', $text);

    // Add space before block tags
    $text = cms_preg_replace_safe('#([^\s])(<' . $_block_tags . '>)#i', '$1 $2', $text);

    // Add space after block tags
    $text = cms_preg_replace_safe('#(</' . $_block_tags . '>)([^\s])#i', '$1 $3', $text);

    // Strip remaining HTML tags
    $text = strip_tags($text);
    $text = @html_entity_decode($text, ENT_QUOTES);

    // Trim each line (as spacing can't be perfected)
    $text = preg_replace('#^ *(.*?) *$#m', '$1', $text);

    return $text;
}

/**
 * Convert GUIDs to IDs in some text.
 *
 * @param  string $text Input text
 * @return string Output text
 */
function convert_guids_to_ids(string $text) : string
{
    if (!addon_installed('commandr')) {
        return $text;
    }

    $matches = [];
    $num_matches = preg_match_all('#^{?([0-9a-fA-F]){8}(-([0-9a-fA-F]){4}){3}-([0-9a-fA-F]){12}}?$#', $text, $matches);
    if ($num_matches != 0) {
        require_code('resource_fs');
        $guids = [];
        for ($i = 0; $i < $num_matches; $i++) {
            $guids[] = $matches[0][$i];
        }
        $mappings = find_ids_via_guids($guids);
        foreach ($mappings as $guid => $id) {
            $text = str_replace($guid, $id, $text);
        }
    }
    return $text;
}

/**
 * Set if a mass-import is in progress.
 *
 * @param  boolean $doing_mass_import If it is
 */
function set_mass_import_mode(bool $doing_mass_import = true)
{
    global $MASS_IMPORT_HAPPENING;
    $MASS_IMPORT_HAPPENING = $doing_mass_import;
}

/**
 * Find if a mass-import is in progress.
 *
 * @return boolean If it is
 */
function get_mass_import_mode() : bool
{
    global $MASS_IMPORT_HAPPENING;
    return $MASS_IMPORT_HAPPENING;
}

/**
 * Run some code. Bail out on failure.
 * We cannot always use this over 'eval' because the code will run in a separate scope.
 *
 * @param  string $code Code to eval
 * @param  string $context A context, used in error messages, to help understand where errors may be
 * @param  boolean $trigger_error Trigger fatal error; even if false an error will be attached
 * @return mixed The error response
 */
function cms_eval(string $code, string $context, bool $trigger_error = true)
{
    push_suppress_error_death(true);

    if (function_exists('error_clear_last')) {
        error_clear_last();
    }
    $errormsg_before = error_get_last();

    try {
        $result = eval($code);
        $attach_manually = false;

        $errormsg = cms_error_get_last();
        if (($errormsg == '') || ($errormsg === $errormsg_before)) {
            $errormsg = '';
        }
    } catch (Exception $e) {
        $result = false;
        $attach_manually = true;

        $errormsg = $e->getMessage();
    } catch (Error $e) {
        $result = false;
        $attach_manually = true;

        $errormsg = $e->getMessage();
    }

    pop_suppress_error_death();

    if (($result === false) && ($errormsg != '')) {
        // It is possible for this to trigger incorrectly. If we've "@"d something, and explicitly returned false, the hidden error will come through.

        if ($trigger_error) {
            fatal_exit(protect_from_escaping(escape_html($context) . ': ' . $errormsg));
        } else {
            if (($attach_manually) && (get_option('error_handling_errors') != 'SKIP')) {
                attach_message(protect_from_escaping(escape_html($context) . ': ' . $errormsg), 'notice'); // Won't attach naturally and won't show in a fatal error, so we must attach it
            }
            // other errors will have still been attached anyway (depending on error_handling_* configuration)
        }
    }

    return $result;
}

/**
 * Exit if we are running on a Google App Engine application (live or development).
 */
function appengine_general_guard()
{
    if (GOOGLE_APPENGINE) {
        warn_exit(do_lang_tempcode('NOT_ON_GOOGLE_APPENGINE'));
    }
}

/**
 * Exit if we are running on a live Google App Engine application.
 */
function appengine_live_guard()
{
    if (appengine_is_live()) {
        warn_exit(do_lang_tempcode('NOT_ON_LIVE_GOOGLE_APPENGINE'));
    }
}

/**
 * Update a catalogue content field reference, to a new value.
 *
 * @param  ID_TEXT $type Content type
 * @param  ID_TEXT $from Old value
 * @param  ID_TEXT $to New value
 */
function update_catalogue_content_ref(string $type, string $from, string $to)
{
    if ($GLOBALS['SITE_DB']->driver->has_update_joins()) {
        $GLOBALS['SITE_DB']->query_update('catalogue_fields f JOIN ' . $GLOBALS['SITE_DB']->get_table_prefix() . 'catalogue_efv_short v ON v.cf_id=f.id', ['cv_value' => $to], ['cv_value' => $from, 'cf_type' => $type]);
    } else {
        $fields = $GLOBALS['SITE_DB']->query_select('catalogue_fields', ['id'], ['cf_type' => $type]);
        foreach ($fields as $field) {
            $GLOBALS['SITE_DB']->query_update('catalogue_efv_short', ['cv_value' => $to], ['cv_value' => $from, 'cf_id' => $field['id']]);
        }
    }
}

/**
 * Start a profiling block, for a specified identifier (of your own choosing).
 *
 * @param  ID_TEXT $identifier Identifier
 */
function cms_profile_start_for(string $identifier)
{
    require_code('profiler');
    _cms_profile_start_for($identifier);
}

/**
 * End a profiling block, for a specified identifier (of your own choosing - but you must have started it with cms_profile_start_for).
 *
 * @param  ID_TEXT $identifier Identifier
 * @param  ?string $specifics Longer details of what happened (e.g. a specific SQL query that ran) (null: none provided)
 */
function cms_profile_end_for(string $identifier, ?string $specifics = null)
{
    require_code('profiler');
    _cms_profile_end_for($identifier, $specifics);
}

/**
 * Get the conventional name of a parameter for a particular file identifier.
 * HTTP POST parameters will have 'e_' prepended to this.
 *
 * @param  ID_TEXT $file File identifier
 * @return ID_TEXT Parameter name
 *
 * @ignore
 */
function get_dynamic_file_parameter(string $file) : string
{
    return str_replace(['/', ':', '.'], ['__', '__', '__'], $file);
}

/**
 * Whether smart decaching is enabled. It is slightly inefficient but makes site development easier for people.
 *
 * @param  boolean $support_temporary_disable Support it being temporarily disabled
 * @return boolean If smart decaching is enabled
 */
function support_smart_decaching(bool $support_temporary_disable = false) : bool
{
    if ($support_temporary_disable) {
        global $DISABLE_SMART_DECACHING_TEMPORARILY;
        if ($DISABLE_SMART_DECACHING_TEMPORARILY) {
            return false;
        }
    }

    static $has_in_url = null;
    if ($has_in_url === null) {
        $has_in_url = (get_param_integer('keep_smart_decaching', 0) == 1);
    }
    if ($has_in_url) {
        return true;
    }

    global $SITE_INFO;
    if (!empty($SITE_INFO['disable_smart_decaching'])) {
        if ($SITE_INFO['disable_smart_decaching'] == '1') {
            return false;
        }

        static $has_temporary = null;
        if ($has_temporary === null) {
            $has_temporary = false;
            $matches = [];
            if (preg_match('#^(\d+):(.*)$#', $SITE_INFO['disable_smart_decaching'], $matches) != 0) {
                $time = intval($matches[1]);
                $path = $matches[2];
                if (is_file($path) && filemtime($path) > time() - $time) {
                    $has_temporary = true;
                }
            }
        }
        return $has_temporary;
    }

    return true; // By default it is on
}

/**
 * For performance reasons disable smart decaching for cases that allow it to be disabled temporarily (it does a lot of file system checks).
 */
function disable_smart_decaching_temporarily()
{
    global $DISABLE_SMART_DECACHING_TEMPORARILY;
    $DISABLE_SMART_DECACHING_TEMPORARILY = true;
}

/**
 * Find if the current request has POST fields worth considering/propagating. Very standard framework fields will be ignored.
 *
 * @return boolean Whether it does
 */
function has_interesting_post_fields() : bool
{
    foreach (array_keys($_POST) as $field_name) {
        if (!is_string($field_name)) {
            $field_name = strval($field_name);
        }

        if (!is_control_field($field_name, false, true)) {
            return true;
        }
    }
    return false;
}

/**
 * Find if a POST field is a password field.
 *
 * @param  string $field_name Field to check
 * @return boolean If it's a password field
 */
function is_password_field(string $field_name) : bool
{
    $password_fields = [
        'password',
        'confirm_password',
        'password_confirm',
        'edit_password',
        'decrypt',
    ];
    return in_array($field_name, $password_fields);
}

/**
 * Find if a POST field is some kind of special control field rather than interesting standalone data.
 *
 * @param  string $field_name Field to check
 * @param  boolean $include_email_metafields Consider e-mail metafields as control fields
 * @param  boolean $include_login_fields Consider login fields as control fields
 * @param  array $extra_boring_fields Additional boring fields to skip
 * @param  boolean $include_password_fields Consider password fields as control fields
 * @return boolean If it's a control field
 */
function is_control_field(string $field_name, bool $include_email_metafields = false, bool $include_login_fields = false, array $extra_boring_fields = [], bool $include_password_fields = false) : bool
{
    // NB: Keep this function synced with the copy of it in static_export.php

    if ($include_password_fields) {
        if (is_password_field($field_name)) {
            return true;
        }
    }

    $boring_fields = [
        // Passed through metadata
        'id',

        // Passed through data
        'stub',

        // Passed through context
        'from_url',
        'http_referer',
        'redirect',
        'redirect_passon',

        // Image buttons send these
        'x',
        'y',

        // Relating to uploads/attachments
        'MAX_FILE_SIZE',

        // Relating to preview
        'perform_webstandards_check',

        // Relating to posting form/WYSIWYG
        'posting_ref_id',
        'f_face',
        'f_colour',
        'f_size',

        // Relating to security
        'session_id',
        'csrf_token',
        'js_token',
        'y' . md5(get_site_name() . ': antispam'),
        'captcha',
        'g-recaptcha-response',

        // Data relaying for Suhosin workaround
        'post_data',
    ];
    if ($include_email_metafields) {
        $boring_fields = array_merge($boring_fields, [
            'subject',
            'title',
            'name',
            'email',
            'to_members_email',
            'to_written_name',
        ]);
    }
    if ($include_login_fields) {
        $boring_fields = array_merge($boring_fields, [
            'username',
            'password',
            'remember_me',
            'login_invisible',
        ]);
    }
    if (in_array($field_name, $boring_fields)) {
        return true;
    }

    if (in_array($field_name, $extra_boring_fields)) {
        return true;
    }

    $prefixes = [
        // Standard hidden-fields convention
        '_',

        // Form metadata
        'label_for__',
        'description_for__',
        'tick_on_form__',
        'require__',
        'comcode_page_hint_',

        // Relating to uploads/attachments
        'hid_file_id_',
        'hidFileName_',

        // Relating to preview
        'pre_f_',
        'tempcodecss__',
        'comcode__',

        // Relating to permissions setting
        'access_',
    ];
    if ($include_email_metafields) {
        $prefixes = array_merge($prefixes, [
            'field_tagged__',
        ]);
    }
    foreach ($prefixes as $prefix) {
        if (substr($field_name, 0, strlen($prefix)) == $prefix) {
            return true;
        }
    }

    $suffixes = [
        // Relating to posting form/WYSIWYG
        '_parsed',
        '__is_wysiwyg',
    ];
    foreach ($suffixes as $suffix) {
        if (substr($field_name, -strlen($suffix)) == $suffix) {
            return true;
        }
    }

    $substrings = [
        // Passed through metadata
        'confirm',
    ];
    foreach ($substrings as $substring) {
        if (strpos($field_name, $substring) !== false) {
            return true;
        }
    }

    if (($field_name[0] == 'x') && (strlen($field_name) == 33)) {
        return true;
    }

    return false;
}

/**
 * Apply escaping for an HTTP header.
 *
 * @param  string $str Text to insert into header
 * @param  boolean $within_quotes Text is between quotes
 * @return string Escaped text
 */
function escape_header(string $str, bool $within_quotes = false) : string
{
    if ($within_quotes) {
        $str = addslashes($str);
    }
    return str_replace(["\r", "\n"], ['', ''], $str);
}

/**
 * Find if running as CLI (i.e. on the command prompt). This implies admin credentials (web users can't initiate a CLI call), and text output.
 *
 * @return boolean Whether running as CLI
 */
function is_cli() : bool
{
    return (php_function_allowed('php_sapi_name')) && (php_sapi_name() == 'cli') && ($_SERVER['REMOTE_ADDR'] == '');
}

/**
 * Get login URL.
 *
 * @return array A triple: URL to log in from, URL to direct login form posts to, URL to join from
 */
function get_login_url() : array
{
    $_lead_source_description = either_param_string('_lead_source_description', '');
    if ($_lead_source_description == '') {
        global $METADATA;
        $_lead_source_description = (isset($METADATA['real_page']) ? $METADATA['real_page'] : get_page_name()) . ' (' . get_self_url_easy() . ')';
    }

    if (has_interesting_post_fields() || (get_page_name() == 'join') || (currently_logging_in()) || (get_page_name() == 'lost_password')) {
        $_this_url = build_url(['page' => ''], '_SELF', ['keep_session' => true]);
    } else {
        $_this_url = build_url(['page' => '_SELF'], '_SELF', ['keep_session' => true, 'redirect' => true], true);
    }

    $url_map = ['page' => 'login', 'type' => 'browse'];
    if ((has_interesting_post_fields()) || (get_option('page_after_login') == '')) {
        $url_map['redirect'] = protect_url_parameter($_this_url);
    }
    $full_url = build_url($url_map, get_module_zone('login'));

    $url_map = ['page' => 'login', 'type' => 'login'];
    if ((has_interesting_post_fields()) || (get_option('page_after_login') == '')) {
        $url_map['redirect'] = protect_url_parameter($_this_url);
    }
    $login_url = build_url($url_map, get_module_zone('login'));

    $join_url = null;
    switch (get_forum_type()) {
        case 'cns':
            $join_url = build_url(['page' => 'join', '_lead_source_description' => $_lead_source_description, 'redirect' => protect_url_parameter($_this_url)], get_module_zone('join'));
            break;

        case 'none':
            $join_url = '';
            break;

        default:
            $join_url = $GLOBALS['FORUM_DRIVER']->join_url(true);
            if (is_string($join_url)) {
                $join_url = make_string_tempcode($join_url);
            }
            break;
    }

    return [$full_url, $login_url, $join_url];
}

/**
 * Find the filesystem owner of the website.
 * Will always return 0 on Windows.
 *
 * @return integer Owner
 */
function website_file_owner() : int
{
    if (running_script('install')) {
        $path_to_check = get_file_base() . '/install.php';
    } else {
        $path_to_check = get_file_base() . '/sources/global.php';
    }
    return fileowner($path_to_check);
}

/**
 * Find the filesystem group of the website.
 * Will always return 0 on Windows.
 *
 * @return integer Group
 */
function website_file_group() : int
{
    if (running_script('install')) {
        $path_to_check = get_file_base() . '/install.php';
    } else {
        $path_to_check = get_file_base() . '/sources/global.php';
    }
    return filegroup($path_to_check);
}

/**
 * Find the creation time of the website.
 *
 * @return TIME Time
 */
function website_creation_time() : int
{
    if (running_script('install')) {
        $path_to_check = get_file_base() . '/install.php';
    } else {
        $path_to_check = get_file_base() . '/sources/global.php';
    }
    return filemtime($path_to_check);
}

/**
 * Find whether a particular feature is currently maintained (only works with particular pre-determined feature codes).
 *
 * @param  ID_TEXT $code Feature
 * @return boolean Maintained status
 */
function is_maintained(string $code) : bool
{
    static $cache = null;
    if ($cache === null) {
        $cache = [];

        global $FILE_ARRAY;
        if (@is_array($FILE_ARRAY)) {
            $file = file_array_get('data/maintenance_status.csv');
            file_put_contents('php://memory', $file);
            $path = 'php://memory';
        } else {
            $path = get_file_base() . '/data/maintenance_status.csv';
        }
        require_code('files_spreadsheets_read');
        $sheet_reader = spreadsheet_open_read($path, 'maintenance_status.csv');
        while (($row = $sheet_reader->read_row()) !== false) {
            $cache[$row['Codename']] = !empty($row['Current active sponsor']);
        }
        $sheet_reader->close();
    }

    if (isset($cache[$code])) {
        return $cache[$code];
    }

    return true;
}

/**
 * Tack on a message to some text if a feature is not maintained.
 *
 * @param  ID_TEXT $code Feature
 * @param  mixed $text Text to show, provided in HTML format (string or Tempcode)
 * @return Tempcode Text
 */
function is_maintained_description(string $code, $text) : object
{
    if (is_string($text)) {
        $text = protect_from_escaping($text);
    }

    if (!is_maintained($code)) {
        return do_lang_tempcode('NON_MAINTAINED_STATUS', $text, make_string_tempcode(escape_html(get_brand_base_url())), escape_html($code));
    }
    return $text;
}

/**
 * Find if a forum post is a spacer post.
 *
 * @param  string $post The spacer post
 * @return array A pair: Whether it is, and the language it is in
 */
function is_spacer_post(string $post) : array
{
    if (substr($post, 0, 10) == '[semihtml]') {
        $post = substr($post, 10);
    }

    $langs = find_all_langs();
    foreach (array_keys($langs) as $lang) {
        $matcher = do_lang('SPACER_POST_MATCHER', null, null, null, $lang);
        if (substr($post, 0, strlen($matcher)) == $matcher) {
            return [true, $lang];
        }
    }
    return [false, get_site_default_lang()];
}

/**
 * Prepare an argument for use literally in a command. Works around common PHP restrictions.
 *
 * @param  string $arg The argument
 * @return string Escaped
 */
function cms_escapeshellarg(string $arg) : string
{
    if (php_function_allowed('escapeshellarg')) {
        return escapeshellarg($arg);
    }
    return "'" . addslashes(str_replace([chr(0), "'"], ['', "'\"'\"'"], $arg)) . "'";
}

/**
 * Get the Internet host name corresponding to a given IP address. Wrapper around gethostbyaddr.
 *
 * @param  string $ip_address IP address
 * @return string Host name OR IP address if failed to look up
 */
function cms_gethostbyaddr(string $ip_address) : string
{
    $hostname = '';

    if ((php_function_allowed('shell_exec')) && (function_exists('get_value')) && (get_value('slow_php_dns') === '1')) {
        if ($hostname == '') {
            $results = shell_exec('host ' . cms_escapeshellarg($ip_address));
            if (is_string($results)) {
                $hostname = trim(preg_replace('#^.* #', '', $results));
            }
        }
    }

    if ($hostname == '') {
        if (php_function_allowed('gethostbyaddr')) {
            $hostname = @gethostbyaddr($ip_address);
        }
    }

    if ($hostname == '') {
        $hostname = $ip_address;
    }

    return rtrim($hostname, '.'); // Normalise with no trailing dot
}

/**
 * Get the IP address corresponding to a given Internet host name. Wrapper around gethostbyname.
 *
 * @param  string $hostname Host name
 * @return string IP address OR host name if failed to look up
 */
function cms_gethostbyname(string $hostname) : string
{
    $ip_address = '';

    if ((php_function_allowed('shell_exec')) && (function_exists('get_value')) && (get_value('slow_php_dns') === '1')) {
        if ($ip_address == '') {
            if (strpos(PHP_OS, 'Linux') !== false) {
                $ip_address = trim(preg_replace('# .*$#', '', shell_exec('getent hosts ' . cms_escapeshellarg($hostname))));
            }
        }

        if ($ip_address == '') {
            $shell_result = shell_exec('host ' . cms_escapeshellarg($hostname));

            if (is_string($shell_result)) {
                $ip_address = preg_replace('#^.*has IPv6 address [\da-f:]+.*#s', '$1', $shell_result);
                if (preg_match('#^[\da-f:]+$#', $ip_address) == 0) {
                    $ip_address = '';
                }

                if ($ip_address == '') {
                    $ip_address = preg_replace('#^.*has address (\d+\.\d+\.\d+\.\d+).*#s', '$1', $shell_result);
                    if (preg_match('#^[\d\.]+$#', $ip_address) == 0) {
                        $ip_address = '';
                    }
                }
            }
        }
    }

    if ($ip_address == '') {
        if (php_function_allowed('gethostbyname')) {
            $ip_address = @gethostbyname($hostname);
        }
    }

    if (empty($ip_address)) {
        $ip_address = $hostname;
    }

    return $ip_address;
}

/**
 * Unpack some bytes to an integer, so we can do some bitwise arithmetic on them.
 * Assumes unsigned, unless you request 4 bytes.
 *
 * @param  string $str Input string
 * @param  ?integer $bytes How many bytes to read (null: as many as there are in $str)
 * @set 1 2 4
 * @param  boolean $little_endian Whether to use little endian (Intel order) as opposed to big endian (network/natural order)
 * @return integer Read integer
 */
function cms_unpack_to_uinteger(string $str, ?int $bytes = null, bool $little_endian = false) : int
{
    if ($bytes === null) {
        $bytes = strlen($str);
    }

    switch ($bytes) {
        case 1:
            $result = unpack('C', $str);
            break;
        case 2:
            $result = unpack($little_endian ? 'v' : 'n', $str);
            break;
        case 4:
            $result = unpack($little_endian ? 'V' : 'N', $str);
            break;
        default:
            warn_exit(do_lang_tempcode('INTERNAL_ERROR'));
    }
    return $result[1];
}

/**
 * Perform a regular expression match.
 * Automatically applies utf-8 if possible and appropriate. \s is not actually Unicode-safe, for example (as it matches non-breaking-spaces).
 * Also automatically applies the 'D' modifier so that trailing blank lines don't mess with '$'.
 *
 * @param  string $pattern The pattern
 * @param  string $subject The subject string
 * @param  ?array $matches Where matches will be put (note that it is a list of maps, except the arrays are turned inside out) (null: do not store matches). Note that this is actually passed by reference, but is also optional. (null: don't gather)
 * @param  integer $flags Either 0, or PREG_OFFSET_CAPTURE
 * @param  integer $offset Offset to start from. Usually use with 'A' modifier to anchor it (using '^' in the pattern will not work)
 * @return ~integer The number of matches (false: error)
 */
function cms_preg_match_safe(string $pattern, string $subject, ?array &$matches = null, int $flags = 0, int $offset = 0)
{
    $pattern .= 'D';
    if (get_charset() == 'utf-8') {
        $result = @preg_match($pattern . 'u', $subject, $matches, $flags, $offset);
        if ($result !== false) {
            return $result;
        }
    }
    return preg_match($pattern, $subject, $matches, $flags, $offset);
}

/**
 * Array entries that match the pattern.
 * Automatically applies utf-8 if possible and appropriate. \s is not actually Unicode-safe, for example (as it matches non-breaking-spaces).
 * Also automatically applies the 'D' modifier so that trailing blank lines don't mess with '$'.
 *
 * @param  string $pattern The pattern
 * @param  array $subject The subject strings
 * @param  integer $flags Either 0, or PREG_GREP_INVERT
 * @return array Matches
 */
function cms_preg_grep_safe(string $pattern, array $subject, int $flags = 0) : array
{
    $pattern .= 'D';
    if (get_charset() == 'utf-8') {
        $result = @preg_grep($pattern . 'u', $subject, $flags);
        if ($result !== false) {
            return $result;
        }
    }
    return preg_grep($pattern, $subject, $flags);
}

/**
 * Perform a global regular expression match.
 * Automatically applies utf-8 if possible and appropriate. \s is not actually Unicode-safe, for example (as it matches non-breaking-spaces).
 * Also automatically applies the 'D' modifier so that trailing blank lines don't mess with '$'.
 *
 * @param  string $pattern The pattern
 * @param  string $subject The subject string
 * @param  ?array $matches Where matches will be put (note that it is a list of maps, except the arrays are turned inside out). Note that this is actually passed by reference, but is also optional. (null: don't gather)
 * @param  integer $flags Either 0, or PREG_OFFSET_CAPTURE
 * @return ~integer The number of matches (false: error)
 */
function cms_preg_match_all_safe(string $pattern, string $subject, ?array &$matches, int $flags = 0)
{
    $pattern .= 'D';
    if (get_charset() == 'utf-8') {
        $result = @preg_match_all($pattern . 'u', $subject, $matches, $flags);
        if ($result !== false) {
            return $result;
        }
    }
    return preg_match_all($pattern, $subject, $matches, $flags);
}

/**
 * Perform a regular expression search and replace.
 * Automatically applies utf-8 if possible and appropriate. \s is not actually Unicode-safe, for example (as it matches non-breaking-spaces).
 * Also automatically applies the 'D' modifier so that trailing blank lines don't mess with '$'.
 *
 * @param  mixed $pattern The pattern (string or array)
 * @param  mixed $replacement The replacement string (string or array)
 * @param  string $subject The subject string
 * @param  integer $limit The limit of replacements (-1: no limit)
 * @return ~string The string with replacements made (false: error)
 */
function cms_preg_replace_safe($pattern, $replacement, string $subject, int $limit = -1)
{
    $pattern .= 'D';
    if (get_charset() == 'utf-8') {
        $result = @preg_replace($pattern . 'u', $replacement, $subject, $limit);
        if ($result !== false) {
            return $result;
        }
    }
    return preg_replace($pattern, $replacement, $subject, $limit);
}

/**
 * Perform a regular expression search and replace using a callback.
 * Automatically applies utf-8 if possible and appropriate. \s is not actually Unicode-safe, for example (as it matches non-breaking-spaces).
 * Also automatically applies the 'D' modifier so that trailing blank lines don't mess with '$'.
 *
 * @param  string $pattern The pattern
 * @param  mixed $callback The callback
 * @param  string $subject The subject string
 * @param  integer $limit The limit of replacements (-1: no limit)
 * @return ~string The string with replacements made (false: error)
 */
function cms_preg_replace_callback_safe(string $pattern, $callback, string $subject, int $limit = -1)
{
    $pattern .= 'D';
    if (get_charset() == 'utf-8') {
        $result = @preg_replace_callback($pattern . 'u', $callback, $subject, $limit);
        if ($result !== false) {
            return $result;
        }
    }
    return preg_replace_callback($pattern, $callback, $subject, $limit);
}

/**
 * Initialise a simple structure for use with cms_preg_safety_guard_ok to protect us within complex regexp loops that keep going so long as stuff is changing.
 *
 * @return array Data structure (meant to be a black box)
 */
function cms_preg_safety_guard_init() : array
{
    $guard = ['i' => 0, 'init_time' => time()];
    return $guard;
}

/**
 * Protect us from infinite loop bugs within complex regexp loops that keep going so long as stuff is changing.
 *
 * @param  array $guard Data structure from cms_preg_safety_guard_init
 * @return boolean Whether it is safe to keep looping
 */
function cms_preg_safety_guard_ok(array &$guard) : bool
{
    if ($guard['i'] > 100 || $guard['init_time'] < time() - 5) {
        // Too thorny, do not continue
        if ($GLOBALS['DEV_MODE']) {
            fatal_exit('Prevented possible infinite loop');
        }
        return false;
    }
    $guard['i']++;
    return true;
}

/**
 * Split string by a regular expression.
 * Automatically applies utf-8 if possible and appropriate. \s is not actually Unicode-safe, for example (as it matches non-breaking-spaces).
 * Also automatically applies the 'D' modifier so that trailing blank lines don't mess with '$'.
 *
 * @param  string $pattern The pattern
 * @param  string $subject The subject
 * @param  integer $max_splits The maximum number of splits to make (-1: no limit)
 * @param  ?integer $mode The special mode (null: none)
 * @return array The array due to splitting
 */
function cms_preg_split_safe(string $pattern, string $subject, int $max_splits = -1, ?int $mode = null) : array
{
    $pattern .= 'D';
    if (get_charset() == 'utf-8') {
        $result = @preg_split($pattern . 'u', $subject, $max_splits, $mode);
        if ($result !== false) {
            return $result;
        }
    }
    return preg_split($pattern, $subject, $max_splits, $mode);
}

/**
 * Put out some benign HTTP output.
 * FastCGI seems to have a weird issue with 'slowish spiky process not continuing with output' - this works around it. Not ideal as would break headers in any subsequent code.
 */
function send_http_output_ping()
{
    global $DOING_OUTPUT_PINGS;
    $DOING_OUTPUT_PINGS = true;

    if ((running_script('index')) && (!is_cli())) {
        if (!headers_sent()) {
            disable_output_compression(); // Otherwise the output handler will squash flushes
            cms_ob_end_clean(); // Otherwise flushing won't help
        }

        echo ' ';
        cms_flush_safe();
    }
}

/**
 * Set the PHP time limit.
 * You will rarely want to use this standalone, as cms_extend_time_limit is more appropriate.
 * However, in computationally-expensive library code this is useful for restoring the execution time to what it was once your code has finished.
 *
 * @param  integer $secs Number of seconds to set (likely a TIME_LIMIT_EXTEND_* constant)
 * @return integer The old time limit
 */
function cms_set_time_limit(int $secs) : int
{
    $previous = intval(ini_get('max_execution_time'));

    if (php_function_allowed('set_time_limit')) {
        @set_time_limit($secs);
    }

    return $previous;
}

/**
 * Disable the PHP time limit.
 *
 * @return integer The old time limit
 */
function cms_disable_time_limit() : int
{
    return cms_set_time_limit(0);
}

/**
 * Extend the PHP time limit.
 *
 * @param  integer $secs Number of seconds to extend (likely a TIME_LIMIT_EXTEND_* constant)
 * @return integer The old time limit
 */
function cms_extend_time_limit(int $secs) : int
{
    $previous = intval(ini_get('max_execution_time'));

    if ($previous == 0) {
        return 0;
    }

    return cms_set_time_limit($previous + $secs);
}

/**
 * Register a function for execution on shutdown.
 * Does not call it if shutdown functions are not available on this server.
 *
 * @param  mixed $callback Callback
 * @param  ?mixed $param_a Parameter (null: not used)
 * @param  ?mixed $param_b Parameter (null: not used)
 * @param  ?mixed $param_c Parameter (null: not used)
 * @param  ?mixed $param_d Parameter (null: not used)
 * @param  ?mixed $param_e Parameter (null: not used)
 * @param  ?mixed $param_f Parameter (null: not used)
 * @param  ?mixed $param_g Parameter (null: not used)
 * @param  ?mixed $param_h Parameter (null: not used)
 * @param  ?mixed $param_i Parameter (null: not used)
 * @param  ?mixed $param_j Parameter (null: not used)
 * @param  ?mixed $param_k Parameter (null: not used)
 * @param  ?mixed $param_l Parameter (null: not used)
 * @param  ?mixed $param_m Parameter (null: not used)
 * @return boolean Whether it scheduled for later (as normally expected)
 */
function cms_register_shutdown_function_if_available($callback, $param_a = null, $param_b = null, $param_c = null, $param_d = null, $param_e = null, $param_f = null, $param_g = null, $param_h = null, $param_i = null, $param_j = null, $param_k = null, $param_l = null, $param_m = null) : bool
{
    if (function_exists('register_shutdown_function')) {
        $args = [$callback, $param_a, $param_b, $param_c, $param_d, $param_e, $param_f, $param_g, $param_h, $param_i, $param_j, $param_k, $param_l, $param_m];
        while ((!empty($args)) && (end($args) === null)) {
            array_pop($args);
        }
        call_user_func_array('register_shutdown_function', $args);
        return true;
    }

    return false;
}

/**
 * Register a function for execution on shutdown.
 * Calls it immediately if shutdown functions are not reliable on this server.
 *
 * @param  mixed $callback Callback
 * @param  ?mixed $param_a Parameter (null: not used)
 * @param  ?mixed $param_b Parameter (null: not used)
 * @param  ?mixed $param_c Parameter (null: not used)
 * @param  ?mixed $param_d Parameter (null: not used)
 * @param  ?mixed $param_e Parameter (null: not used)
 * @param  ?mixed $param_f Parameter (null: not used)
 * @param  ?mixed $param_g Parameter (null: not used)
 * @param  ?mixed $param_h Parameter (null: not used)
 * @param  ?mixed $param_i Parameter (null: not used)
 * @param  ?mixed $param_j Parameter (null: not used)
 * @param  ?mixed $param_k Parameter (null: not used)
 * @param  ?mixed $param_l Parameter (null: not used)
 * @param  ?mixed $param_m Parameter (null: not used)
 * @return boolean Whether it scheduled for later (as normally expected)
 */
function cms_register_shutdown_function_safe($callback, $param_a = null, $param_b = null, $param_c = null, $param_d = null, $param_e = null, $param_f = null, $param_g = null, $param_h = null, $param_i = null, $param_j = null, $param_k = null, $param_l = null, $param_m = null) : bool
{
    if (function_exists('get_value') && get_value('avoid_register_shutdown_function') === '1' || !function_exists('register_shutdown_function')) {
        $args = [$param_a, $param_b, $param_c, $param_d, $param_e, $param_f, $param_g, $param_h, $param_i, $param_j, $param_k, $param_l, $param_m];
        while ((!empty($args)) && (end($args) === null)) {
            array_pop($args);
        }
        call_user_func_array($callback, $args);

        return false;
    }

    if (function_exists('register_shutdown_function')) {
        $args = [$callback, $param_a, $param_b, $param_c, $param_d, $param_e, $param_f, $param_g, $param_h, $param_i, $param_j, $param_k, $param_l, $param_m];
        while ((!empty($args)) && (end($args) === null)) {
            array_pop($args);
        }
        call_user_func_array('register_shutdown_function', $args);
        return true;
    }

    return false; // Should never get here
}

/**
 * See how much to implement a view count, based on smart metrics.
 *
 * @param  string $table Table to update in
 * @param  integer $view_count Current view count
 * @return integer How much to increment the view count by
 */
function statistical_update_model(string $table, int $view_count) : int
{
    if (get_db_type() == 'xml') { // Too much overhead
        return 0;
    }

    if (get_bot_type() !== null) {
        return 0;
    }

    if (get_value('disable_view_counts') === '1') {
        return 0;
    }

    if ((get_value('disable_view_counts') === '-1') && ($GLOBALS['FORUM_DRIVER']->is_staff(get_member()))) {
        return 0;
    }

    if (get_value('statistical_update_model') == '1') {
        $st_increment = max(1, intval(round(floatval($view_count) / 20.0)));
    } else {
        $st_increment = 1;
    }

    if ($st_increment == 0) {
        return 0;
    }

    if (mt_rand(1, $st_increment) != 1) {
        return 0;
    }

    if ($GLOBALS['SITE_DB']->table_is_locked($table)) {
        return 0;
    }

    return $st_increment;
}

/**
 * Create a cookie, inside Composr's cookie environment.
 *
 * @param  string $name The name of the cookie
 * @param  string $value The value to store in the cookie
 * @param  boolean $session Whether it is a session cookie (gets removed once the browser window closes)
 * @param  boolean $httponly Whether the cookie should not be readable by JavaScript
 * @param  ?float $days Days to store (null: default); not applicable for session cookies
 * @return boolean The result of the PHP setcookie command
 */
function cms_setcookie(string $name, string $value, bool $session = false, bool $httponly = true, ?float $days = null) : bool
{
    /*if (($GLOBALS['DEV_MODE']) && (running_script('index')) && (get_forum_type() == 'cns') && (get_param_integer('keep_debug_has_cookies', 0) == 0) && ($name != 'has_referers')) {    Annoying, and non-cookie support is very well tested by now
        return true;
    }*/

    static $cache = [];
    $sz = serialize([$name, $value, $session, $httponly]);
    if (isset($cache[$sz])) {
        return $cache[$sz];
    }

    if ($days === null) {
        $days = get_cookie_days();
    }
    $expires = ($session ? 0 : (time() + intval($days * 24.0 * 60.0 * 60.0)));

    $path = get_cookie_path();
    if ($path == '') {
        $base_url = get_base_url();
        $pos = strpos($base_url, '/');
        if ($pos === false) {
            $path = '/';
        } else {
            $path = substr($base_url, $pos) . '/';
        }
    }

    $domain = get_cookie_domain();

    $secure = (substr(get_base_url(), 0, 7) === 'https://');

    if (version_compare(PHP_VERSION, '7.3.0', '>=')) { // LEGACY
        $options = [
            'expires' => $expires,
            'path' => $path,
            'domain' => $domain,
            'secure' => $secure,
            'httponly' => $httponly,
        ];

        // Stops HTTP POSTs from external sites inheriting cookie value.
        //  Note that Lax is not necessarily the same as setting no value.
        //  That said for Chrome 80+ all are Lax by default.
        //  Tracked at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite
        $options['samesite'] = 'Lax';

        $output = @call_user_func_array('setcookie', [$name, $value, $options]);
    } else {
        $output = @setcookie($name, $value, $expires, $path, $domain, $secure, $httponly);
    }

    if ($name != 'has_cookies') {
        $_COOKIE[$name] = $value;
    }

    $cache[$sz] = $output;

    return $output;
}

/**
 * Convert a parameter set from a an array (for PHP code) to a string (for templates).
 *
 * @param  array $map The parameters / acceptable parameter pattern
 * @return string The parameters / acceptable parameter pattern, as template safe parameter
 */
function comma_list_arr_to_str(array $map) : string
{
    ksort($map);

    $str = '';

    foreach ($map as $key => $val) {
        if ($str != '') {
            $str .= ',';
        }
        if ((is_integer($key)) && (strpos($val, '=') !== false)) { // {$BLOCK} style, i.e. a list not a map
            list($_key, $_val) = explode('=', $val, 2);
            $str .= $_key . '=' . str_replace('=', '\=', str_replace(',', '\,', str_replace('\\', '\\\\', $_val)));
        } else {
            if (!is_string($key)) {
                $key = strval($key);
            }
            $str .= $key . '=' . str_replace('=', '\=', str_replace(',', '\,', str_replace('\\', '\\\\', $val)));
        }
    }

    return $str;
}

/**
 * Convert a parameter set from a string (for templates) to an array (for PHP code).
 *
 * @param  string $str The parameters / acceptable parameter pattern, as template safe parameter
 * @param  boolean $block_symbol_style Whether to leave in block symbol style (i.e. like {$BLOCK} would take, a list not a map)
 * @return array The parameters / acceptable parameter pattern
 */
function comma_list_str_to_arr(string $str, bool $block_symbol_style = false) : array
{
    if ($str == '') {
        return [];
    }

    if (($GLOBALS['XSS_DETECT']) && (ocp_is_escaped($str))) {
        $ocp_is_escaped = true;
    } else {
        $ocp_is_escaped = false;
    }

    $map = [];
    $len = strlen($str);
    if ($block_symbol_style) {
        // Get the parts without splitting keys and values, leave escaping as-is
        $escaped = false;
        $in_key = true;
        $key = '';
        $val = '';
        for ($i = 0; $i < $len; $i++) {
            $c = $str[$i];
            if ($in_key) {
                if ($c == ',') {
                    if ($ocp_is_escaped) {
                        ocp_mark_as_escaped($key);
                    }
                    $map[] = $key;
                    $key = '';
                    continue;
                } elseif ($c == '=') {
                    $in_key = false;
                } else {
                    $key .= $c;
                }
            } else {
                if ($escaped) {
                    $escaped = false;
                    $val .= '\\' . $c;
                } else {
                    if ($c == '\\') {
                        $escaped = true;
                    } elseif ($c == ',') {
                        $in_key = true;
                        if ($ocp_is_escaped) {
                            ocp_mark_as_escaped($key);
                            ocp_mark_as_escaped($val);
                        }
                        $map[] = $key . '=' . $val;
                        $key = '';
                        $val = '';
                    } else {
                        $val .= $c;
                    }
                }
            }
        }
        if ($in_key) {
            if ($ocp_is_escaped) {
                ocp_mark_as_escaped($key);
            }
            $map[] = $key;
        } else {
            if ($ocp_is_escaped) {
                ocp_mark_as_escaped($key);
                ocp_mark_as_escaped($val);
            }
            $map[] = $key . '=' . $val;
        }
    } else {
        $escaped = false;
        $in_key = true;
        $key = '';
        $val = '';
        for ($i = 0; $i < $len; $i++) {
            $c = $str[$i];
            if ($in_key) {
                if ($c == ',') {
                    if ($ocp_is_escaped) {
                        ocp_mark_as_escaped($key);
                    }
                    $map[] = $key;
                    $key = '';
                    continue;
                } elseif ($c == '=') {
                    $in_key = false;
                } else {
                    $key .= $c;
                }
            } else {
                if ($escaped) {
                    $escaped = false;
                    $val .= $c;
                } else {
                    if ($c == '\\') {
                        $escaped = true;
                    } elseif ($c == ',') {
                        $in_key = true;
                        if ($ocp_is_escaped) {
                            ocp_mark_as_escaped($key);
                            ocp_mark_as_escaped($val);
                        }
                        if ($key == '' && isset($map[$key])) {
                            $map[] = $val;
                        } else {
                            $map[$key] = $val;
                        }
                        $key = '';
                        $val = '';
                    } else {
                        $val .= $c;
                    }
                }
            }
        }
        if ($in_key) {
            if ($ocp_is_escaped) {
                ocp_mark_as_escaped($key);
            }
            $map[] = $key;
        } else {
            if ($ocp_is_escaped) {
                ocp_mark_as_escaped($key);
                ocp_mark_as_escaped($val);
            }
            if ($key == '' && isset($map[$key])) {
                $map[] = $val;
            } else {
                $map[$key] = $val;
            }
        }
    }

    return $map;
}
