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

/**
 * Change whatever global context that is required in order to run from a different context.
 *
 * @sets_input_state
 *
 * @param  array $new_get The URL component map (must contain 'page')
 * @param  ID_TEXT $new_zone The zone
 * @param  ID_TEXT $new_current_script The running script
 * @param  boolean $erase_keep_also Whether to get rid of keep_ variables in current URL
 * @param  ?boolean $new_in_self_routing_script Whether we are in a self-routing script (null: if new_current_script is 'index')
 * @param  boolean $replace_existing_get Replace all existing GET parameters
 * @return array A list of parameters that would be required to be passed back to reset the state
 */
function set_execution_context(array $new_get, string $new_zone = '_SEARCH', string $new_current_script = 'index', bool $erase_keep_also = false, ?bool $new_in_self_routing_script = null, bool $replace_existing_get = true) : array
{
    global $IN_SELF_ROUTING_SCRIPT;

    $old_get = $_GET;
    $old_zone = get_zone_name();
    $old_current_script = current_script();
    $old_in_self_routing_script = $IN_SELF_ROUTING_SCRIPT;

    if ($replace_existing_get) {
        foreach ($_GET as $key => $val) {
            if (is_integer($key)) {
                $key = strval($key);
            }

            if ((substr($key, 0, 5) != 'keep_') || ($erase_keep_also)) {
                unset($_GET[$key]);
            }
        }
    }

    foreach ($new_get as $key => $val) {
        if ($val !== null) {
            $_GET[$key] = is_integer($val) ? strval($val) : $val;
        } else {
            unset($_GET[$key]);
        }
    }

    global $PAGE_NAME_CACHE;
    $PAGE_NAME_CACHE = null;

    global $RELATIVE_PATH, $ZONE, $SELF_URL_CACHED;
    $RELATIVE_PATH = ($new_zone == '_SEARCH') ? get_page_zone(get_page_name()) : $new_zone;
    if ($new_zone != $old_zone) {
        $ZONE = null; // So zone details will have to reload
    }
    $SELF_URL_CACHED = null;

    if ($new_in_self_routing_script === null) {
        $IN_SELF_ROUTING_SCRIPT = ($new_current_script == 'index');
    } else {
        $IN_SELF_ROUTING_SCRIPT = $new_in_self_routing_script;
    }
    global $RUNNING_SCRIPT_CACHE, $WHAT_IS_RUNNING_CACHE;
    $RUNNING_SCRIPT_CACHE = [];
    $WHAT_IS_RUNNING_CACHE = $new_current_script;

    return [$old_get, $old_zone, $old_current_script, $old_in_self_routing_script, true];
}

/**
 * Map spaces to %20 and put http:// in front of URLs starting www.
 *
 * @param  URLPATH $url The URL to fix
 * @return URLPATH The fixed result
 */
function remove_url_mistakes(string $url) : string
{
    if (substr($url, 0, 4) == 'www.') {
        $url = 'https://' . $url;
    }
    $url = @html_entity_decode($url, ENT_NOQUOTES);
    $url = str_replace(' ', '%20', $url);
    $url = preg_replace('#keep_session=\w*#', 'filtered=1', $url);
    return $url;
}

/**
 * Get hidden fields for a form representing 'keep_x'. If we are having a GET form instead of a POST form, we need to do this. This function also encodes the page name, as we'll always want that.
 *
 * @param  ID_TEXT $page The page for the form to go to (blank: don't attach)
 * @param  boolean $keep_all Whether to keep all elements of the current URL represented in this form (rather than just the keep_ fields, and page)
 * @param  array $exclude A list of parameters to exclude
 * @return Tempcode The built-up hidden form fields
 *
 * @ignore
 */
function _build_keep_form_fields(string $page = '', bool $keep_all = false, array $exclude = []) : object
{
    if ($page == '_SELF') {
        $page = get_page_name();
    }
    $out = new Tempcode();

    foreach ($_GET as $key => $val) {
        if (is_integer($key)) {
            $key = strval($key);
        }

        $process_for_key = ((is_string($key)) && (substr($key, 0, 5) == 'keep_') || ($keep_all)) && (!in_array($key, $exclude)) && ($key != 'page') && (!skippable_keep($key, $val));
        if (!$process_for_key) {
            continue;
        }

        if (is_array($val)) {
            foreach ($val as $_key => $_val) { // We'll only support one level deep. Also no keep parameter array support.
                if (!is_string($_val)) {
                    continue; // Nested $_POST arrays should not happen in the software, but may happen by hack-bots
                }

                if (is_integer($_key)) {
                    $_key = strval($_key);
                }

                $out->attach(form_input_hidden($key . '[' . $_key . ']', $_val));
            }
        } elseif (is_string($val)) {
            if (is_integer($key)) {
                $key = strval($key);
            }

            $out->attach(form_input_hidden($key, $val));
        }
    }

    if ($page != '') {
        $out->attach(form_input_hidden('page', $page));
    }

    return $out;
}

/**
 * Recursion helper function for _build_keep_post_fields.
 *
 * @param  ID_TEXT $key Key name to put value under
 * @param  mixed $value Value (string or array)
 * @return string The built-up hidden form fields
 *
 * @ignore
 */
function _fixed_post_parser(string $key, $value) : string
{
    $out = '';

    if (!is_string($key)) {
        $key = strval($key);
    }

    if (is_array($value)) {
        foreach ($value as $k => $v) {
            if (is_string($k)) {
                $out .= _fixed_post_parser($key . '[' . $k . ']', $v);
            } else {
                $out .= _fixed_post_parser($key . '[' . strval($k) . ']', $v);
            }
        }
    } else {
        $out .= static_evaluate_tempcode(form_input_hidden($key, is_string($value) ? $value : strval($value)));
    }

    return $out;
}

/**
 * Relay all POST parameters for this URL, to the URL embedded in the form.
 *
 * @param  array $exclude A list of parameters to exclude
 * @param  boolean $force_everything Force field labels and descriptions to copy through even when there are huge numbers of parameters
 * @return Tempcode The built-up hidden form fields
 *
 * @ignore
 */
function _build_keep_post_fields(array $exclude = [], bool $force_everything = false) : object
{
    $out = '';
    foreach ($_POST as $key => $val) {
        if (is_integer($key)) {
            $key = strval($key);
        }

        if ((in_array($key, $exclude)) || ($key == 'session_id'/*for spam blackhole*/) || ($key == 'csrf_token')) {
            continue;
        }

        if (count($_POST) > 80 && !$force_everything) {
            if (substr($key, 0, 14) == 'tick_on_form__') {
                continue;
            }
            if (substr($key, 0, 11) == 'label_for__') {
                continue;
            }
            if (substr($key, 0, 9) == 'require__') {
                continue;
            }
        }

        $out .= _fixed_post_parser($key, $val);
    }
    return make_string_tempcode($out);
}

/**
 * Takes a URL, and converts it into a file system storable filename. This is used to cache URL contents to the servers filesystem.
 *
 * @param  URLPATH $url_full The URL to convert to an encoded filename
 * @return string A usable filename based on the URL
 *
 * @ignore
 */
function _url_to_filename(string $url_full) : string
{
    // We don't need this anymore since we are hashing
    /*
    $bad_chars = ['!', '/', '\\', '?', '*', '<', '>', '|', '"', ':', '%', '!', ';', '~', ' '];
    $new_name = $url_full;
    foreach ($bad_chars as $bad_char) {
        $good_char = '~' . strval(ord($bad_char));
        if ($bad_char == ':') {
            $good_char = ';'; // So page_links save nicely
        }
        $new_name = str_replace($bad_char, $good_char, $new_name);
    }
    */

    require_code('crypt');
    $_new_name = hash_hmac('sha256', $url_full, get_site_salt(), true);
    $new_name = str_replace(['+','/','='], ['-','_',''], base64_encode($_new_name));

    if (strpos($new_name, '.') !== false) {
        $new_name .= '.' . get_file_extension($url_full);
    }

    return $new_name;
}

/**
 * Take a URL and base URL, and fully qualify the URL according to it.
 *
 * @param  URLPATH $url The URL to fully qualified
 * @param  URLPATH $url_base The base URL
 * @param  boolean $base_is_full_url Whether the base URL is actually a full URL which needs stripping back
 * @return URLPATH Fully qualified URL
 *
 * @ignore
 */
function _qualify_url(string $url, string $url_base, bool $base_is_full_url) : string
{
    if ($base_is_full_url) {
        $url_base = dirname($url_base);
    }

    require_code('crypt');
    $mto = mailto_obfuscated();
    if (($url != '') && ($url[0] != '{') && ($url[0] != '#') && (substr($url, 0, 5) != 'data:') && (substr($url, 0, 7) != 'mailto:') && (substr($url, 0, strlen($mto)) != $mto)) {
        if (url_is_local($url)) {
            if ($url[0] == '/') {
                $parsed = @cms_parse_url_safe($url_base);
                if ($parsed === false) {
                    return '';
                }
                if (!array_key_exists('host', $parsed)) {
                    $parsed['host'] = get_base_url_hostname();
                }
                if (substr($url, 0, 2) == '//') {
                    $url = $parsed['scheme'] . ':' . $url;
                } else {
                    $url = $parsed['scheme'] . '://' . $parsed['host'] . (array_key_exists('port', $parsed) ? (':' . $parsed['port']) : '') . $url;
                }
            } else {
                $url = $url_base . '/' . $url;
            }
        }
    }

    $url = str_replace('/./', '/', $url);
    $pos = strpos($url, '/../');
    while ($pos !== false) {
        $pos_2 = strrpos(substr($url, 0, $pos - 1), '/');
        if ($pos_2 === false) {
            break;
        }
        $url = substr($url, 0, $pos_2) . '/' . substr($url, $pos + 4);
        $pos = strpos($url, '/../');
    }

    return $url;
}

/**
 * Convert a URL to a local file path.
 *
 * @param  URLPATH $url The value to convert
 * @return ?PATH File path (null: is not local)
 * @ignore
 */
function _convert_url_to_path(string $url) : ?string
{
    $url = preg_replace('#\?\d+$#', '', $url);
    if (strpos($url, '?') !== false) {
        return null;
    }
    if (substr($url, -4) == '.php') {
        return null;
    }

    $url = str_replace('://www.', '://', $url);
    $bu = str_replace('://www.', '://', get_base_url());
    $cbu = str_replace('://www.', '://', get_custom_base_url());

    if (
        (strpos($url, '://') === false) ||
        (substr($url, 0, strlen($bu) + 1) == $bu . '/') ||
        (substr($url, 0, strlen($cbu) + 1) == $cbu . '/')
    ) {
        if (substr($url, 0, strlen($bu) + 1) == $bu . '/') {
            $file_path_stub = urldecode(substr($url, strlen($bu) + 1));
        } elseif (substr($url, 0, strlen($cbu) + 1) == $cbu . '/') {
            $file_path_stub = urldecode(substr($url, strlen($cbu) + 1));
        } else {
            $file_path_stub = urldecode($url);
        }
        if (((substr($file_path_stub, 0, 7) == 'themes/') && (substr($file_path_stub, 0, 15) != 'themes/default/')) || (substr($file_path_stub, 0, 8) == 'uploads/') || (strpos($file_path_stub, '_custom/') !== false)) {
            $_file_path_stub = get_custom_file_base() . '/' . $file_path_stub;
            if (!is_file($_file_path_stub)) {
                $_file_path_stub = get_file_base() . '/' . $file_path_stub;
            }
        } else {
            $_file_path_stub = get_file_base() . '/' . $file_path_stub;
        }

        if (!is_file($_file_path_stub)) {
            return null;
        }

        return $_file_path_stub;
    }

    return null;
}

/**
 * Sometimes users don't enter full URLs but do intend for them to be absolute. This code tries to see what relative URLs are actually absolute ones, via an algorithm. It then fixes the URL.
 *
 * @param  URLPATH $in The URL to fix
 * @return URLPATH The fixed URL (or original one if no fix was needed)
 * @ignore
 */
function _fixup_protocolless_urls(string $in) : string
{
    if ($in == '') {
        return $in;
    }

    $in = remove_url_mistakes($in); // Chain in some other stuff

    if (strpos($in, ':') !== false) {
        return $in; // Absolute (e.g. http:// or mailto:)
    }

    if (substr($in, 0, 1) == '#') {
        return $in;
    }
    if (substr($in, 0, 1) == '%') {
        return $in;
    }
    if (substr($in, 0, 1) == '{') {
        return $in;
    }

    // Rule 1: // If we have a dot somewhere before a slash, then this dot is likely part of a domain name (not a file extension)- thus we have an absolute URL.
    if (preg_match('#\..*/#', $in) != 0) {
        return 'https://' . $in; // Fix it
    }
    // Rule 2: // If we have no slashes and we don't recognise a file type then they've probably just entered a domain name- thus we have an absolute URL.
    if ((preg_match('#^[^/]+$#', $in) != 0) && (preg_match('#\.(php|htm|asp|jsp|png|jpg|jpe|txt|pdf|odt|ods|odp|doc|mdb|xls|xml|rss|ppt|svg|gif|psd|rtf|bmp|avi|mpg|mpe|webm|mp4|mov|wmv|ram|rm|asf|ra|wma|wav|mp3|ogg|torrent|csv|ttf|tar|gz|rar|bz2)#', $in) == 0)) {
        return 'https://' . $in . '/'; // Fix it
    }

    return $in; // Relative
}

/**
 * Convert a local URL to a page-link.
 *
 * @param  URLPATH $url The URL to convert. Note it may not be a URL Scheme, and it must be based on the local base URL (else failure WILL occur).
 * @param  boolean $abs_only Whether to only convert absolute URLs. Turn this on if you're not sure what you're passing is a URL not and you want to be extra safe.
 * @param  boolean $perfect_only Whether to only allow perfect conversions
 * @return string The page-link (blank: could not convert)
 *
 * @ignore
 */
function _url_to_page_link(string $url, bool $abs_only = false, bool $perfect_only = true) : string
{
    if (($abs_only) && (substr($url, 0, 7) != 'http://') && (substr($url, 0, 8) != 'https://')) {
        return '';
    }

    $url_in = $url;

    // Try and strip any variants of the base URL from our $url variable, to make it relative
    $non_www_base_url = str_replace('https://www.', 'https://', str_replace('http://www.', 'http://', get_base_url()));
    $www_base_url = str_replace('https://', 'https://www.', str_replace('http://', 'http://www.', get_base_url()));
    $url = preg_replace('#^' . preg_quote(get_base_url() . '/', '#') . '#', '', $url);
    $url = preg_replace('#^' . preg_quote($non_www_base_url . '/', '#') . '#', '', $url);
    $url = preg_replace('#^' . preg_quote($www_base_url . '/', '#') . '#', '', $url);
    if (substr($url, 0, 7) == 'http://') {
        return '';
    }
    if (substr($url, 0, 8) == 'https://') {
        return '';
    }
    if (substr($url, 0, 1) != '/') {
        $url = '/' . $url;
    }

    // Parse the URL
    $parsed_url = @cms_parse_url_safe($url);
    if ($parsed_url === false) {
        require_code('site');
        attach_message(do_lang_tempcode('HTTP_DOWNLOAD_BAD_URL', escape_html($url)), 'warn', false, true);
        return '';
    }

    // Work out the zone
    $slash_pos = strpos($parsed_url['path'], '/', 1);
    $zone = ($slash_pos !== false) ? substr($parsed_url['path'], 1, $slash_pos - 1) : '';
    if (!in_array($zone, find_all_zones())) {
        $zone = '';
        $slash_pos = false;
    }
    $parsed_url['path'] = ($slash_pos === false) ? substr($parsed_url['path'], 1) : substr($parsed_url['path'], $slash_pos + 1); // everything AFTER the zone
    $parsed_url['path'] = preg_replace('#/index\.php$#', '', $parsed_url['path']);
    $attributes = [];
    $attributes['page'] = ''; // hopefully will get overwritten with a real one

    // Convert URL Scheme path info into extra implied attribute data
    require_code('url_remappings');
    $does_match = false;
    $url_schemes = array_unique([get_option('url_scheme'), 'PG', 'HTM', 'SIMPLE', 'RAW']);
    foreach ($url_schemes as $url_scheme) {
        $remappings = get_remappings($url_scheme);
        foreach ($remappings as $_remapping) { // e.g. [['page' => 'wiki', 'id' => null], 'pg/s/ID', true],
            if ($_remapping === null) {
                continue;
            }

            list($remapping, $match_string,) = $_remapping;
            $match_string_pattern = preg_replace('#[A-Z]+#', '[^\&\?]+', preg_quote($match_string)); // Turn match string into a regexp

            $does_match = (preg_match('#^' . $match_string_pattern . '#', $parsed_url['path']) != 0);
            if ($does_match) {
                $attributes = array_merge($attributes, $remapping);

                if ($url_scheme == 'HTM') {
                    if (strpos($parsed_url['path'], '.htm') === false) {
                        continue;
                    }

                    // This just adds complexity we don't need
                    $_match_string = preg_replace('#\.htm$#', '', $match_string);
                    $_path = preg_replace('#\.htm($|\?)#', '', $parsed_url['path']);
                } else {
                    if (strpos($parsed_url['path'], '.htm') !== false) {
                        continue;
                    }

                    $_match_string = $match_string;
                    $_path = $parsed_url['path'];
                }

                $bits_pattern = explode('/', $_match_string);
                $bits_real = explode('/', $_path, count($bits_pattern));

                foreach ($bits_pattern as $i => $bit) {
                    if ($bit == 'DEFAULT_PAGE') {
                        $bit = get_zone_default_page($zone);
                    }

                    if ((cms_strtoupper_ascii($bit) == $bit) && (array_key_exists(cms_strtolower_ascii($bit), $remapping)) && ($remapping[cms_strtolower_ascii($bit)]) === null) {
                        $attributes[cms_strtolower_ascii($bit)] = $bits_real[$i];
                    }
                }

                foreach ($attributes as &$attribute) {
                    if ($attribute === false) {
                        $attribute = get_zone_default_page($zone);
                    }

                    $attribute = cms_urldecode_post_process(urldecode($attribute));
                }

                break 2;
            }
        }
    }
    if (!$does_match) {
        return ''; // No match was found
    }

    // Parse query string component into the waiting (and partly-filled-already) attribute data array
    if (array_key_exists('query', $parsed_url)) {
        $bits = explode('&', $parsed_url['query']);
        foreach ($bits as $bit) {
            $_bit = explode('=', $bit, 2);

            if (count($_bit) == 2) {
                $attributes[$_bit[0]] = cms_urldecode_post_process(urldecode($_bit[1]));
                if (strpos($attributes[$_bit[0]], ':') !== false) {
                    if ($perfect_only) {
                        return ''; // Could not convert this URL to a page-link, because it contains a colon
                    }
                    unset($attributes[$_bit[0]]);
                }
            }
        }
    }

    $page = $attributes['page']; // Any incorrect dashing will be fixed inside process_url_monikers, if there's a moniker

    // Resolve monikers back to canonical URL parameters
    $type = array_key_exists('type', $attributes) ? $attributes['type'] : null;
    $id = array_key_exists('id', $attributes) ? $attributes['id'] : null;
    if (!process_url_monikers(false, false, $page, $zone, $type, $id, false)) {
        $page = fix_page_name_dashing($zone, $attributes['page']); // Not via a moniker
    }

    require_code('site');
    if (_request_page($page, $zone, null, fallback_lang()) === false) {
        return '';
    }

    // Put it together
    $page_link = $zone . ':' . $page;
    if ($type !== null) {
        $page_link .= ':' . $type;
    } elseif ($id !== null) {
        $page_link .= ':';
    }
    if ($id !== null) {
        if (!is_numeric($attributes['id'])) {
            $moniker_id = $GLOBALS['SITE_DB']->query_select_value_if_there('url_id_monikers', 'm_resource_id', ['m_resource_page' => $page, 'm_resource_type' => isset($attributes['type']) ? $attributes['type'] : 'browse', 'm_moniker' => $attributes['id']]);
            if ($moniker_id !== null) {
                $attributes['id'] = $moniker_id;
            }
        }

        $page_link .= ':' . $attributes['id'];
    }
    $devtest_there = array_key_exists('keep_devtest', $attributes);
    if ($devtest_there) {
        unset($attributes['keep_devtest']);
    }
    foreach ($attributes as $key => $val) {
        if (!is_string($val)) {
            $val = strval($val);
        }

        if (($key != 'page') && ($key != 'type') && ($key != 'id')) {
            $page_link .= ':' . $key . '=' . cms_urlencode($val);
        }
    }

    // Hash bit?
    if ((array_key_exists('fragment', $parsed_url)) && ($parsed_url['fragment'] != '')) {
        $page_link .= '#' . $parsed_url['fragment'];
    }

    // Confirm it loops correctly
    if ($perfect_only) {
        push_no_keep_context();
        $conv_url = page_link_to_url($page_link . ($devtest_there ? ':keep_devtest=1' : ''));
        pop_no_keep_context();
        if ($conv_url != $url_in) {
            return '';
        }
    }

    return $page_link;
}

/**
 * Called from 'find_id_moniker'. We tried to lookup a moniker, found a hook, but found no stored moniker. So we'll try and auto-generate one.
 *
 * @param  array $ob_info The hooks info profile
 * @param  array $url_parts The URL component map (must contain 'page', 'type', and 'id' if this function is to do anything)
 * @param  ID_TEXT $zone The URL zone name (only used for Comcode Page URL monikers)
 * @return ?string The moniker ID (null: error generating it somehow, can not do it)
 */
function autogenerate_new_url_moniker(array $ob_info, array $url_parts, string $zone) : ?string
{
    $effective_id = ($url_parts['type'] == '') ? $url_parts['page'] : $url_parts['id'];

    push_db_scope_check(false);
    require_code('content');
    $select = [];
    append_content_select_for_fields($select, $ob_info, ['id', 'title', 'parent_category']);
    $db = get_db_for($ob_info['table']);
    $where = get_content_where_for_str_id($effective_id, $ob_info);
    if (isset($where['the_zone'])) {
        $where['the_zone'] = $zone;
    }
    $_moniker_src = $db->query_select($ob_info['table'], $select, $where, '', null, 0, true); // NB: For Comcode pages visited, this won't return anything -- it will become more performant when the page actually loads, so the moniker won't need redoing each time
    if ($_moniker_src === null) {
        return null; // table missing?
    }
    pop_db_scope_check();
    if (!array_key_exists(0, $_moniker_src)) {
        return null; // been deleted?
    }

    if ($ob_info['id_field_numeric']) {
        $title_field = $ob_info['title_field'];
        if (is_array($title_field)) {
            $title_field = array_pop($title_field); // Anything ahead is just stuff we need to preload for the "CALL:" to work
        }

        if (substr($title_field, 0, 5) == 'CALL:') {
            $moniker_src = call_user_func(trim(substr($title_field, 5)), $_moniker_src[0]);
        } else {
            if ($ob_info['title_field_dereference']) {
                $moniker_src = get_translated_text($_moniker_src[0][$title_field]);
            } else {
                $moniker_src = $_moniker_src[0][$title_field];
            }
        }
    } else {
        $moniker_src = $effective_id;
    }

    if ($moniker_src == '') {
        $moniker_src = 'untitled';
    }

    return suggest_new_idmoniker_for($url_parts['page'], isset($url_parts['type']) ? $url_parts['type'] : '', $url_parts['id'], $zone, $moniker_src, true);
}

/**
 * Called when content is added, or edited/moved, based upon a new form field that specifies what moniker to use.
 *
 * @param  ID_TEXT $page Page name
 * @param  ID_TEXT $type Screen type code
 * @param  ID_TEXT $id Resource ID
 * @param  ID_TEXT $zone The URL zone name (only used for Comcode Page URL monikers)
 * @param  string $moniker_src String from which a moniker will be chosen (may not be blank)
 * @param  boolean $is_new Whether we are sure this is a new moniker (makes things more efficient, saves a query)
 * @param  ?string $moniker Actual moniker to use (null: generate from $moniker_src). Usually this is left null.
 * @return string The chosen moniker
 */
function suggest_new_idmoniker_for(string $page, string $type, string $id, string $zone, string $moniker_src, bool $is_new = false, ?string $moniker = null) : string
{
    if (get_option('url_monikers_enabled') == '0') {
        return '';
    }

    static $force_called = [];
    $ref = $zone . ':' . $page . ':' . $type . ':' . $id;
    if ($moniker !== null) {
        $force_called[$ref] = $moniker;
    } else {
        if (isset($force_called[$ref])) {
            return $force_called[$ref];
        }
    }

    $manually_chosen_now = ($moniker !== null);

    if (!$is_new) {
        if (!$manually_chosen_now) {
            $_manually_chosen = $GLOBALS['SITE_DB']->query_select_value_if_there('url_id_monikers', 'm_moniker', ['m_manually_chosen' => 1, 'm_resource_page' => $page, 'm_resource_type' => $type, 'm_resource_id' => $id]);
            if ($_manually_chosen !== null) {
                return $_manually_chosen;
            }
        }

        // Deprecate old one(s) if already existing (there should only be 1 non-deprecated, but possible DB state may have gotten into a mess somehow)
        $old_moniker_okay = null;
        $old_monikers = $GLOBALS['SITE_DB']->query_select('url_id_monikers', ['m_moniker', 'id'], ['m_resource_page' => $page, 'm_resource_type' => $type, 'm_resource_id' => $id, 'm_deprecated' => 0], 'ORDER BY id DESC');
        foreach (collapse_1d_complexity('m_moniker', $old_monikers) as $old) {
            // See if it is same as current
            if ($moniker === null) {
                $scope = _give_moniker_scope($page, $type, $id, $zone, '');
                $moniker = $scope . _choose_moniker($page, $type, $id, $moniker_src, $old, $scope);
            }
            if ($moniker == $old) {
                $old_moniker_okay = $old; // hmm, ok it can stay actually
                continue;
            }

            // It's not. Although, the later call to _choose_moniker will allow us to use the same stem as the current active one, or even re-activate an old deprecated one, so long as it is on this same m_resource_page/m_resource_page/m_resource_id.

            // Deprecate
            $GLOBALS['SITE_DB']->query_update('url_id_monikers', ['m_deprecated' => 1], ['m_resource_page' => $page, 'm_resource_type' => $type, 'm_resource_id' => $id, 'm_deprecated' => 0], '', 1); // Deprecate

            // Deprecate anything underneath
            global $CONTENT_OBS;
            load_moniker_hooks();
            $looking_for = '_SEARCH:' . $page . ':' . $type . ':_WILD';
            $ob_info = isset($CONTENT_OBS[$looking_for]) ? $CONTENT_OBS[$looking_for] : null;
            if ($ob_info !== null) {
                $parts = explode(':', $ob_info['view_page_link_pattern']);
                $category_page = $parts[1];
                $GLOBALS['SITE_DB']->query('UPDATE ' . get_table_prefix() . 'url_id_monikers SET m_deprecated=1 WHERE ' . db_string_equal_to('m_resource_page', $category_page) . ' AND m_moniker LIKE \'' . db_encode_like($old . '/%') . '\''); // Deprecate
            }
        }

        if ($old_moniker_okay !== null) {
            return $old_moniker_okay;
        }
    }

    if ($moniker === null) {
        $scope = _give_moniker_scope($page, $type, $id, $zone, '');
        $moniker = $scope . _choose_moniker($page, $type, $id, $moniker_src, null, $scope);

        if (($page == 'news') && ($type == 'view') && (get_value('google_news_urls') === '1')) {
            $moniker .= '-' . str_pad($id, 3, '0', STR_PAD_LEFT);
        }
    }

    // Insert
    $GLOBALS['SITE_DB']->query_delete('url_id_monikers', [
        // It's possible we're re-activating a deprecated one
        'm_resource_page' => $page,
        'm_resource_type' => $type,
        'm_moniker' => $moniker,
    ], '', 1);
    $GLOBALS['SITE_DB']->query_insert('url_id_monikers', [
        'm_resource_page' => $page,
        'm_resource_type' => $type,
        'm_resource_id' => $id,
        'm_moniker' => $moniker,
        'm_moniker_reversed' => strrev($moniker),
        'm_deprecated' => 0,
        'm_manually_chosen' => $manually_chosen_now ? 1 : 0,
    ]);

    global $LOADED_MONIKERS_CACHE;
    $LOADED_MONIKERS_CACHE = [];

    return $moniker;
}

/**
 * Delete an old moniker, and place a new one.
 *
 * @param  ID_TEXT $page Page name
 * @param  ID_TEXT $type Screen type code
 * @param  ID_TEXT $id Resource ID
 * @param  string $moniker_src String from which a moniker will be chosen (may not be blank)
 * @param  ?string $no_exists_check_for Whether to skip the exists check for a certain moniker (will be used to pass "existing self" for edits) (null: nothing existing to check against)
 * @param  ?string $scope_context Where the moniker will be placed in the moniker URL tree (null: unknown, so make so no duplicates anywhere)
 * @return string Chosen moniker
 *
 * @ignore
 */
function _choose_moniker(string $page, string $type, string $id, string $moniker_src, ?string $no_exists_check_for = null, ?string $scope_context = null) : string
{
    $moniker = _generate_moniker($moniker_src);

    $basic_sql = ' FROM ' . get_table_prefix() . 'url_id_monikers WHERE ' . db_string_equal_to('m_resource_page', $page);
    if ($type == '') {
        $basic_sql .= ' AND ' . db_string_equal_to('m_resource_id', $id);
    } else {
        $basic_sql .= ' AND ' . db_string_equal_to('m_resource_type', $type) . ' AND ' . db_string_not_equal_to('m_resource_id', $id);
    }

    // Check it does not already exist
    $moniker_origin = $moniker;
    $next_num = 1;
    if (is_numeric($moniker)) {
        $moniker .= '-1';
    }
    $test = null;
    do {
        if ($no_exists_check_for !== null) {
            if ($moniker == preg_replace('#^.*/#', '', $no_exists_check_for)) {
                return $moniker; // This one is okay, we know it is safe
            }
        }

        $dupe_sql = 'SELECT m_resource_id' . $basic_sql . ' AND (';
        if ($scope_context !== null) {
            $dupe_sql .= db_string_equal_to('m_moniker', $scope_context . $moniker);
        } else {
            // Use reversing for better indexing performance
            $dupe_sql .= db_string_equal_to('m_moniker_reversed', strrev($moniker));
            $dupe_sql .= ' OR m_moniker_reversed LIKE \'' . db_encode_like(strrev('%/' . $moniker)) . '\'';
        }
        $dupe_sql .= ')';
        $dupe_sql .= ' AND m_deprecated=0';
        $test = $GLOBALS['SITE_DB']->query_value_if_there($dupe_sql, false, true);
        if ($test !== null) { // Oh dear, will pass to next iteration, but trying a new moniker
            if ($next_num == 1) {
                // Individual checks get very slow with time, so do a jump ahead based on how much is already under a suffixed version of the $moniker (as we can assume sequentiality)
                $accelerate_sql = 'SELECT COUNT(*)' . $basic_sql . ' AND m_moniker LIKE \'' . db_encode_like($moniker . '%') . '\'';
                $next_num += $GLOBALS['SITE_DB']->query_value_if_there($accelerate_sql);
                if ($next_num == 1) {
                    $next_num++;
                }
            } else {
                $next_num++;
            }
            $moniker = $moniker_origin . '-' . strval($next_num);
        }
    } while ($test !== null);

    return $moniker;
}

/**
 * Generate a moniker from an arbitrary raw string. Does not perform uniqueness checks.
 *
 * @param  string $moniker_src Raw string
 * @return ID_TEXT Moniker
 *
 * @ignore
 */
function _generate_moniker(string $moniker_src) : string
{
    $moniker = strip_comcode($moniker_src);

    $max_moniker_length = min(80, intval(get_option('max_moniker_length')));

    // Transliteration first
    if (get_option('moniker_transliteration') == '1') {
        require_code('character_sets');
        $moniker = transliterate_string($moniker);
    }

    // Substitute to force it to be URL-ready
    $moniker = str_replace("'", '', $moniker);
    $moniker = cms_mb_strtolower(preg_replace('#[^' . URL_CONTENT_REGEXP . ']#', '-', $moniker));

    // Strip stop words
    $moniker_bak = $moniker;
    require_code('textfiles');
    $bad_word_text = read_text_file('too_common_words', user_lang());
    $bad_word_text = preg_replace("#\n\n.+$#s", '', $bad_word_text);
    $bad = array_flip(explode("\n", $bad_word_text));
    unset($bad['']);
    $bad = array_flip($bad);
    foreach ($bad as $bad_word) {
        $moniker = preg_replace('#(^|\-)' . preg_quote($bad_word, '#') . '(\-|$)#Ui', '-', $moniker);
    }
    if (trim($moniker, '-') == '') {
        $moniker = $moniker_bak;
    }

    // Strip down by length
    if (cms_mb_strlen($moniker) > $max_moniker_length) {
        $pos = strrpos(cms_mb_substr($moniker, 0, $max_moniker_length), '-');
        if (($pos === false) || ($pos < 12)) {
            $pos = $max_moniker_length;
        }
        $moniker = cms_mb_substr($moniker, 0, $pos);
    }

    // Strip extraneous dashes
    $moniker = preg_replace('#\-+#', '-', $moniker);
    $moniker = trim($moniker, '-');

    // No numbers
    if (is_numeric($moniker)) {
        $moniker = '-' . $moniker;
    }

    // Generic, but maybe we'll have to
    if ($moniker == '') {
        $moniker = 'untitled';
    }

    return $moniker;
}

/**
 * Take a moniker and it's page-link details, and make a full path from it.
 *
 * @param  ID_TEXT $page Page name
 * @param  ID_TEXT $type Screen type code
 * @param  ID_TEXT $id Resource ID
 * @param  ID_TEXT $zone The URL zone name (only used for Comcode Page URL monikers)
 * @param  string $main Pathless moniker
 * @return string The fully qualified moniker
 *
 * @ignore
 */
function _give_moniker_scope(string $page, string $type, string $id, string $zone, string $main) : string
{
    // Does this URL arrangement support monikers?
    global $CONTENT_OBS;
    load_moniker_hooks();
    $found = false;
    if ($type == '') {
        $looking_for = '_WILD:_WILD';
    } else {
        $looking_for = '_SEARCH:' . $page . ':' . $type . ':_WILD';
    }

    $ob_info = isset($CONTENT_OBS[$looking_for]) ? $CONTENT_OBS[$looking_for] : null;

    $moniker = $main;

    if ($ob_info === null) {
        return $moniker;
    }

    if ($ob_info['parent_category_field'] !== null) {
        if ($ob_info['parent_category_field'] == 'the_zone') {
            $ob_info['parent_category_field'] = 'p_parent_page'; // Special exception for Comcode page monikers
        }

        // Lookup DB record so we can discern the category
        push_db_scope_check(false);
        require_code('content');
        $select = [];
        append_content_select_for_fields($select, $ob_info, ['id', 'title', 'parent_category']);
        $where = get_content_where_for_str_id(($type == '') ? $page : $id, $ob_info);
        if (isset($where['the_zone'])) {
            $where['the_zone'] = $zone;
        }
        $_moniker_src = $GLOBALS['SITE_DB']->query_select($ob_info['table'], $select, $where, '', null, 0, true);
        if ($_moniker_src === null) {
            return $moniker; // table missing?
        }
        pop_db_scope_check();
        if (!array_key_exists(0, $_moniker_src)) {
            return $moniker; // been deleted?
        }

        // Discern the path (will effectively recurse, due to find_id_moniker call)
        $parent = $_moniker_src[0][$ob_info['parent_category_field']];
        if (is_integer($parent)) {
            $parent = strval($parent);
        }
        if (($parent === null) || ($parent === 'root') || ($parent === '') || ($parent == strval(db_get_first_id()))) {
            $tree = null;
        } else {
            $view_category_page_link_pattern = explode(':', $ob_info['view_category_page_link_pattern']);
            if ($type == '') {
                $tree = find_id_moniker(['page' => $parent], $zone);
            } else {
                $tree = find_id_moniker(['page' => $view_category_page_link_pattern[1], 'type' => $view_category_page_link_pattern[2], 'id' => $parent], $zone);
            }
        }

        // Okay, so our full tree path is as follows
        if ($tree !== null) {
            $moniker = $tree . '/' . $main;
        }
    }

    return $moniker;
}

/**
 * Find the ID from a content-type & moniker combination.
 *
 * @param  ID_TEXT $content_type The content type
 * @param  SHORT_TEXT $url_moniker The URL moniker
 * @return ?ID_TEXT The ID (null: not found)
 */
function find_id_via_url_moniker(string $content_type, string $url_moniker) : ?string
{
    $path = 'hooks/systems/content_meta_aware/' . filter_naughty_harsh($content_type, true);
    if ((!file_exists(get_file_base() . '/sources/' . $path . '.php')) && (!file_exists(get_file_base() . '/sources_custom/' . $path . '.php'))) {
        return null;
    }

    require_code($path);

    require_code('content');
    $cma_ob = object_factory('Hook_content_meta_aware_' . filter_naughty_harsh($content_type, true));
    $cma_info = $cma_ob->info();
    if ($cma_info === null) {
        return null;
    }
    if (!$cma_info['support_url_monikers']) {
        return null;
    }

    list(, $url_bits) = page_link_decode($cma_info['view_page_link_pattern']);
    $where = ['m_resource_page' => $url_bits['page'], 'm_resource_type' => $url_bits['type'], 'm_moniker' => $url_moniker];

    $ret = $cma_info['db']->query_select_value_if_there('url_id_monikers', 'm_resource_id', $where);
    return $ret;
}

/**
 * Find a unique path on the filesystem, and corresponding URL and filename.
 *
 * @param  string $subdir Subdirectory relative to base directory
 * @param  ?string $filename Filename (null: random)
 * @param  boolean $lock_in Whether to write out an empty string to the file so that some other code (e.g. from another request) doesn't try and use the same path
 * @param  ?string $conflict_okay_if_matching Can use a duplicated file path if it matches this data (null: no check)
 * @return array A tuple: path, relative URL for path, filename used
 */
function find_unique_path(string $subdir, ?string $filename = null, bool $lock_in = false, ?string $conflict_okay_if_matching = null) : array
{
    if ($filename === null) {
        require_code('crypt');
        $filename = get_secure_random_string() . '.bin';
    }

    $ext = get_file_extension($filename);
    if ($ext == '') {
        $ext = 'bin';
        $filename .= '.bin';
    }

    $basename = basename($filename, '.' . $ext);

    $i = 1;
    do {
        if ($i == 1) {
            $adjusted_filename = $filename;
        } else {
            $adjusted_filename = $basename . '_' . strval($i) . '.' . $ext;
        }
        $path = get_custom_file_base() . '/' . $subdir . '/' . $adjusted_filename;
        $i++;

        if ((is_file($path)) && ($conflict_okay_if_matching !== null)) {
            if (cms_file_get_contents_safe($path, FILE_READ_LOCK) == $conflict_okay_if_matching) {
                break;
            }
        }
    } while (is_file($path));

    if ($lock_in) {
        require_code('files');
        cms_file_put_contents_safe($path, ''); // Lock it in ASAP, to stop race conditions
    }

    $url = cms_rawurlrecode(str_replace('%2F', '/', rawurlencode($subdir . '/' . $adjusted_filename)));

    return [$path, $url, $adjusted_filename];
}

/**
 * Check to see if a URL exists.
 *
 * @param  URLPATH $url The URL to check
 * @param  ?integer $test_freq_secs Cache must be newer than this many seconds (null: default of 31 days)
 * @param  boolean $retry_on_failed Whether to automatic retry if previously failed, rather than trusting that previous result
 * @param  integer $attempts Number of times to attempt before considering failing this time
 * @param  string $message HTTP response code, returned by reference
 * @param  string $destination_url HTTP destination URL
 * @return boolean Whether it does
 */
function check_url_exists(string $url, ?int $test_freq_secs = null, bool $retry_on_failed = true, int $attempts = 1, string &$message = '', string &$destination_url = '') : bool
{
    if ($test_freq_secs === null) {
        $test_freq_secs = 2678400;
    }

    $url = qualify_url($url, get_base_url());

    if (substr($url, 0, 1) == '#') {
        return true;
    }
    if (substr($url, 0, 7) == 'mailto:') {
        return true;
    }

    $test1 = $GLOBALS['SITE_DB']->query_select('urls_checked', ['*'], ['url' => $url], 'ORDER BY url_check_time DESC', 1);

    if ((!isset($test1[0])) || ($test1[0]['url_check_time'] < time() - $test_freq_secs) || (($retry_on_failed) && ($test1[0]['url_exists'] == 0))) {
        $old = cms_extend_time_limit($attempts * 6); // Provide a PHP time extension depending on how many attempts we're making

        for ($i = 0; $i < $attempts; $i++) {
            $test2 = cms_http_request($url, ['trigger_error' => false]);
            if (($test2 !== null) && (in_array($test2->message, ['400', '401', '403', '405', '416', '500', '501', '502', '503', '520']))) {
                $test2 = cms_http_request($url, ['trigger_error' => false, 'byte_limit' => 1]); // Try without HEAD, sometimes it's not liked
            }
            $exists = ($test2->data !== null);
            if ($exists) {
                break;
            }
        }

        cms_set_time_limit($old);

        mark_if_url_exists($url, $exists, ($test2->message === null) ? '' : $test2->message);
        $message = $test2->message;
        $destination_url = $test2->download_url;
    } else {
        $exists = ($test1[0]['url_exists'] == 1);
        $message = $test1[0]['response_message'];
        $destination_url = $test1[0]['url_destination_url'];
    }

    return $exists;
}

/**
 * Mark if a URL exists.
 *
 * @param  URLPATH $url The URL
 * @param  boolean $exists Whether it exists
 * @param  string $message HTTP response code (blank: unknown)
 * @param  URLPATH $destination_url Destination URL (blank: unknown)
 */
function mark_if_url_exists(string $url, bool $exists = true, string $message = '', string $destination_url = '')
{
    $GLOBALS['SITE_DB']->query_delete(
        'urls_checked',
        [
            'url' => $url,
        ]
    );
    $GLOBALS['SITE_DB']->query_insert(
        'urls_checked',
        [
            'url_exists' => $exists ? 1 : 0,
            'response_message' => $message,
            'url_destination_url' => $destination_url,
            'url_check_time' => time(),
            'url' => $url,
        ]
    );
}
