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

/*

Notes about hook info...
 - id_field may be array (which means that ":" works as a delimiter) (if so, the first one is the main ID, while the second one is assumed to be a qualifier)
  - unless, parent_spec__table_name!=table, where we require a single id_field, knowing it is a join field in all tables
 - category_field may be array of two (if so, the second one is assumed the main category, while the first is assumed to be for supplemental permission checking)
 - category_field may be null
 - permission_module may be array
 - permission_module may be null
 - permission_module may be missing

*/

/**
 * Standard code module initialisation function.
 *
 * @ignore
 */
function init__content()
{
    if (!defined('FIELD_RENDER_PLAIN')) {
        define('FIELD_RENDER_PLAIN', 1);
        define('FIELD_RENDER_COMCODE', 2);
        define('FIELD_RENDER_HTML', 3);

        define('PROMINENCE_WEIGHT_NONE', 0);
        define('PROMINENCE_WEIGHT_LOWEST', 1);
        define('PROMINENCE_WEIGHT_LOW', 2);
        define('PROMINENCE_WEIGHT_MEDIUM', 3);
        define('PROMINENCE_WEIGHT_HIGH', 4);
        define('PROMINENCE_WEIGHT_HIGHEST', 5);

        define('PROMINENCE_FLAG_ACTIVE_ONLY', 1);
        define('PROMINENCE_FLAG_PINNED', 2);

        define('IMAGE_URL_FALLBACK_NONE', 0);
        define('IMAGE_URL_FALLBACK_SOFT', 1);
        define('IMAGE_URL_FALLBACK_HARD', 2);
    }
}

/**
 * Given a particular bit of feedback content, check if the user may access it.
 *
 * @param  MEMBER $member_id User to check
 * @param  ID_TEXT $content_type Content type
 * @param  ID_TEXT $content_id Content ID
 * @param  ID_TEXT $type_has Type of content type / ID
 * @return boolean Whether there is permission
 */
function may_view_content_behind(int $member_id, string $content_type, string $content_id, string $type_has = 'content_type') : bool
{
    $permission_module = convert_cms_type_codes($type_has, $content_type, 'permission_module');

    $module = convert_cms_type_codes($type_has, $content_type, 'module');
    if ($module == '') {
        $module = $content_id;
    }

    $category_id = null;
    $real_content_type = convert_cms_type_codes($type_has, $content_type, 'content_type');
    if ($real_content_type != '') {
        $content_type_ob = get_content_object($real_content_type);
        $info = $content_type_ob->info();
        if (isset($info['category_field'])) {
            list(, , , $content) = content_get_details($real_content_type, $content_id);
            if ($content !== null) {
                $category_field = $info['category_field'];
                if (is_array($category_field)) {
                    $category_field = array_pop($category_field);
                    $category_id = is_integer($content[$category_field]) ? strval($content[$category_field]) : $content[$category_field];
                    if ($real_content_type == 'catalogue_entry') {
                        $catalogue_name = $GLOBALS['SITE_DB']->query_select_value('catalogue_categories', 'c_name', ['id' => $category_id]);
                        if (!has_category_access($member_id, 'catalogues_catalogue', $catalogue_name)) {
                            return false;
                        }
                    }
                } else {
                    $category_id = is_integer($content[$category_field]) ? strval($content[$category_field]) : $content[$category_field];
                }
            }
        }
    }

    // FUDGE: Extra check for private topics
    $topic_id = null;
    if (($real_content_type == 'post') && (get_forum_type() == 'cns')) {
        $post_rows = $GLOBALS['FORUM_DB']->query_select('f_posts', ['p_topic_id', 'p_whisper_to_member', 'p_posting_member'], ['id' => intval($content_id)], '', 1);
        if (!array_key_exists(0, $post_rows)) {
            return false;
        }
        if (($post_rows[0]['p_whisper_to_member'] !== null) && (($post_rows[0]['p_whisper_to_member'] != $member_id) && ($post_rows[0]['p_posting_member'] != $member_id) || (is_guest($member_id)))) {
            return false;
        }
        $topic_id = $post_rows[0]['p_topic_id'];
    }
    if (($real_content_type == 'topic') && (get_forum_type() == 'cns')) {
        $topic_id = intval($content_id);
    }
    if ($topic_id !== null) {
        $topic_rows = $GLOBALS['FORUM_DB']->query_select('f_topics', ['t_pt_to_member', 't_pt_from_member', 't_forum_id', 't_validated', 't_cache_first_member_id'], ['id' => $topic_id], '', 1);
        if (!array_key_exists(0, $topic_rows)) {
            return false;
        }
        require_code('cns_topics');
        if (!cns_may_access_topic($topic_id, $member_id, $topic_rows[0])) {
            return false;
        }
    }

    if (!has_actual_page_access($member_id, $module)) {
        return false;
    }

    // Privacy
    if (addon_installed('content_privacy')) {
        require_code('content_privacy');
        if (!has_privacy_access($real_content_type, $content_id)) {
            return false;
        }
    }

    if ($category_id !== null) {
        if (is_array($permission_module)) {
            $permission_module = array_pop($permission_module); // FUDGE: Catalogue permission checked above as special case, so we only want the catalogue category permission
        }
        if ($permission_module != '') {
            if (!has_category_access($member_id, $permission_module, $category_id)) {
                return false;
            }
        }
    }

    return true;
}

/**
 * Get the CMA hook object for a content type. Also works for resource types (i.e. if it's a resource, although not actually considered content technically).
 *
 * @param  ID_TEXT $content_type The content type
 * @return ?object The object (null: could not get one)
 */
function get_content_object(string $content_type) : ?object
{
    static $cache = [];
    if (isset($cache[$content_type])) {
        return $cache[$content_type];
    }

    $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'))) {
        require_code($path);
        $ob = object_factory('Hook_content_meta_aware_' . filter_naughty_harsh($content_type, true), true);
    } else {
        // Okay, maybe it's a resource type (more limited functionality).
        $path = 'hooks/systems/resource_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'))) {
            require_code('hooks/systems/resource_meta_aware/' . filter_naughty_harsh($content_type, true));
            $ob = object_factory('Hook_resource_meta_aware_' . filter_naughty_harsh($content_type, true), true);
        } else {
            $ob = null;
        }
    }

    $cache[$content_type] = $ob;
    return $ob;
}

/**
 * Find a different content type code from the one had.
 *
 * @param  ID_TEXT $type_has Content type we know
 * @set addon content_type meta_hook search_hook seo_type_code feedback_type_code permission_module module table commandr_filesystem_hook rss_hook attachment_hook notification_hook sitemap_hook
 * @param  ID_TEXT $type_id Content type ID we know
 * @param  ID_TEXT $type_wanted Desired content type
 * @set addon content_type meta_hook search_hook seo_type_code feedback_type_code permission_module module table commandr_filesystem_hook rss_hook attachment_hook notification_hook sitemap_hook
 * @return mixed Corrected content type (blank: could not find)
 */
function convert_cms_type_codes(string $type_has, string $type_id, string $type_wanted)
{
    $real_type_wanted = $type_wanted;

    $type_id = preg_replace('#^catalogue_entry__[' . URL_CONTENT_REGEXP . ']+_#', 'catalogue_entry_', $type_id);

    // Search content-meta-aware hooks
    $found_type_id = '';
    $cma_hooks = find_all_hooks('systems', 'content_meta_aware') + find_all_hooks('systems', 'resource_meta_aware');
    foreach (array_keys($cma_hooks) as $content_type) {
        if ((($type_has == 'content_type') && ($content_type == $type_id)) || ($type_has != 'content_type')) {
            $cma_ob = get_content_object($content_type);
            $cma_info = $cma_ob->info();
            if ($cma_info !== null) {
                $cma_info['content_type'] = $content_type;
                if ((isset($cma_info[$type_has], $cma_info[$type_wanted])) && (($cma_info[$type_has] == $type_id) || ($cma_info[$type_has] == preg_replace('#__.*$#', '', $type_id)))) {
                    $found_type_id = $cma_info[$type_wanted];
                    break;
                }
            }
        }
    }

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

/**
 * Find content type info, for a particular content type we know.
 *
 * @param  ID_TEXT $type_has Content type we know
 * @set addon content_type meta_hook search_hook seo_type_code feedback_type_code permission_module module table commandr_filesystem_hook rss_hook attachment_hook notification_hook sitemap_hook
 * @param  ID_TEXT $type_id Content type ID we know
 * @return array Content type info list (blank: could not find)
 */
function convert_cms_type_codes_multiple(string $type_has, string $type_id) : array
{
    $type_id = preg_replace('#^catalogue_entry__[' . URL_CONTENT_REGEXP . ']+_#', 'catalogue_entry_', $type_id);

    // Search content-meta-aware hooks
    $found_type_ids = [];
    $cma_hooks = find_all_hooks('systems', 'content_meta_aware') + find_all_hooks('systems', 'resource_meta_aware');
    foreach (array_keys($cma_hooks) as $content_type) {
        if ((($type_has == 'content_type') && ($content_type == $type_id)) || ($type_has != 'content_type')) {
            $cma_ob = get_content_object($content_type);
            $cma_info = $cma_ob->info();
            if ($cma_info !== null) {
                $cma_info['content_type'] = $content_type;
                if ((isset($cma_info[$type_has])) && (($cma_info[$type_has] == $type_id) || ($cma_info[$type_has] == preg_replace('#__.*$#', '', $type_id)))) {
                    $found_type_ids[] = $cma_info;
                }
            }
        }
    }

    return $found_type_ids;
}

/**
 * Get meta details of a content item.
 *
 * @param  ID_TEXT $content_type Content type
 * @param  ID_TEXT $content_id Content ID
 * @param  boolean $resource_fs_style Whether to use the content API as resource-fs requires (may be slightly different)
 * @return array Tuple: title, submitter, content hook info, the content row, URL (for use within current browser session), URL (for use in e-mails / sharing), Content object
 */
function content_get_details(string $content_type, string $content_id, bool $resource_fs_style = false) : array
{
    $cma_ob = get_content_object($content_type);
    if (!is_object($cma_ob)) {
        warn_exit(do_lang_tempcode('INTERNAL_ERROR'));
    }
    $cma_info = $cma_ob->info();

    if ($cma_info === null) {
        return [null, null, null, null, null, null, null];
    }

    $db = $cma_info['db'];

    $content_row = content_get_row($content_id, $cma_info);
    if ($content_row === null) {
        // FUDGE
        if (($content_type == 'comcode_page') && (strpos($content_id, ':') !== false) && (!$resource_fs_style)) {
            list($zone, $page) = explode(':', $content_id, 2);

            require_code('users_active_actions');
            $submitter_id = get_first_admin_user();

            require_code('global4');
            $content_row = [
                'the_zone' => $zone,
                'the_page' => $page,
                'p_parent_page' => '',
                'p_validated' => 1,
                'p_edit_date' => null,
                'p_add_date' => time(),
                'p_submitter' => $submitter_id,
                'p_show_as_edit' => 0,
                'p_include_on_sitemap' => comcode_page_include_on_sitemap($zone, $page) ? 1 : 0,
            ];

            $content_url = build_url(['page' => $page], $zone, [], false, false, false);
            $content_url_email_safe = build_url(['page' => $page], $zone, [], false, false, true);

            $_content_title = $GLOBALS['SITE_DB']->query_select_value_if_there('cached_comcode_pages', 'cc_page_title', ['the_zone' => $zone, 'the_page' => $page]);
            if ($_content_title !== null) {
                $content_title = get_translated_text($_content_title);
            } else {
                $content_title = $zone . ':' . $page;
            }

            return [$content_title, $submitter_id, $cma_info, $content_row, $content_url, $content_url_email_safe, $cma_ob];
        }

        return [null, null, $cma_info, null, null, null, $cma_ob];
    }

    $falled_back_to_id = false;
    $content_title = $cma_ob->get_title($content_row, FIELD_RENDER_PLAIN, $falled_back_to_id, $resource_fs_style);

    $submitter_id = $cma_ob->get_submitter($content_row);
    if ($submitter_id === null) {
        $submitter_id = $GLOBALS['FORUM_DRIVER']->get_guest_id();
    }

    if ($cma_info['view_page_link_pattern'] !== null) {
        $content_url = $cma_ob->get_view_url($content_row, false);
        $content_url_email_safe = $cma_ob->get_view_url($content_row, true);
    } else {
        $content_url = null;
        $content_url_email_safe = null;
    }

    return [$content_title, $submitter_id, $cma_info, $content_row, $content_url, $content_url_email_safe, $cma_ob];
}

/**
 * Get the content row of a content item.
 *
 * @param  ID_TEXT $content_id The content ID
 * @param  array $cma_info The info array for the content type
 * @return ?array The row (null: not found)
 */
function content_get_row(string $content_id, array $cma_info) : ?array
{
    static $cache = [];
    $cache_key = $cma_info['table'] . '.' . $content_id;
    if (isset($cache[$cache_key])) {
        return $cache[$cache_key];
    }

    $db = $cma_info['db'];

    $id_field_numeric = array_key_exists('id_field_numeric', $cma_info) ? $cma_info['id_field_numeric'] : true;
    $where = get_content_where_for_str_id($content_id, $cma_info);
    $_content = $db->query_select($cma_info['table'] . ' r', ['r.*'], $where, '', 1);

    $ret = array_key_exists(0, $_content) ? $_content[0] : null;
    $cache[$cache_key] = $ret;
    return $ret;
}

/**
 * Get the string content ID for some data.
 *
 * @param  array $data The data row
 * @param  array $cma_info The info array for the content type
 * @return ID_TEXT The ID
 */
function extract_content_str_id_from_data(array $data, array $cma_info) : string
{
    $id_field = $cma_info['id_field'];
    $id = '';
    $id_field_parts = is_array($id_field) ? $id_field : [$id_field];
    $id_field_parts = array_reverse($id_field_parts);
    foreach ($id_field_parts as $id_field_part) {
        if ($id != '') {
            $id .= ':';
        }
        $id .= (is_integer($data[$id_field_part]) ? strval($data[$id_field_part]) : $data[$id_field_part]);
    }
    return $id;
}

/**
 * Fill up a SELECT clause with actual fields needed to select particular meta-fields.
 *
 * @param  array $select The ID
 * @param  array $cma_info The info array for the content type
 * @param  array $fields The list of the standardised field names from hooks
 * @set id parent_category category title description image views order submitter author add_time edit_time date validated video
 * @param  ?string $table_alias The table alias (null: none)
 */
function append_content_select_for_fields(array &$select, array $cma_info, array $fields, ?string $table_alias = null)
{
    if (in_array('image', $fields)) { // We may actually query against full_image too
        $fields[] = 'full_image';
    }
    if (in_array('title', $fields)) { // We may need a fall-back
        $fields[] = 'id';
    }

    foreach ($fields as $field) {
        if (isset($cma_info[$field . '_field'])) {
            foreach (is_array($cma_info[$field . '_field']) ? $cma_info[$field . '_field'] : [$cma_info[$field . '_field']] as $field_part) {
                if (strpos($field_part, 'CALL:') === false) {
                    $select[] = (($table_alias === null) ? '' : ($table_alias . '.')) . $field_part;
                }
            }
        }
    }
    $select = array_unique($select);
}

/**
 * Given the string content ID get a mapping we could use as a WHERE map.
 *
 * @param  ID_TEXT $str_id The ID
 * @param  array $cma_info The info array for the content type
 * @param  ?string $table_alias The table alias (null: none)
 * @return array The mapping
 */
function get_content_where_for_str_id(string $str_id, array $cma_info, ?string $table_alias = null) : array
{
    $where = [];
    $id_field = $cma_info['id_field'];
    $id_parts = is_array($id_field) ? explode(':', $str_id) : [$str_id];
    $id_parts = array_reverse($id_parts);
    foreach (is_array($id_field) ? $id_field : [$id_field] as $i => $id_field_part) {
        $val = array_key_exists($i, $id_parts) ? $id_parts[$i] : '';
        $where[(($table_alias === null) ? '' : ($table_alias . '.')) . $id_field_part] = $cma_info['id_field_numeric'] ? @intval($val) : $val;
    }
    return $where;
}

/**
 * Get content rows matching certain parameters, across multiple content types.
 *
 * @param  array $content_types List of content types to return for
 * @param  ?integer $days Day limit for recency (null: no limit)
 * @param  mixed $extra_where Extra WHERE SQL (either a string, or a map between content types and strings)
 * @param  mixed $extra_join Extra JOIN SQL (either a string, or a map between content types and strings)
 * @param  string $sort URL-style sort parameter
 * @param  integer $start Start offset
 * @param  ?integer $max Maximum results to return (null: no limit)
 * @param  mixed $select Selectcode (either a string, or a map between content types and strings)
 * @param  mixed $select_b Selectcode for secondary category
 * @param  mixed $filter Filtercode (either a string, or a map between content types and strings)
 * @param  boolean $check_perms Whether to check permissions
 * @param  ?array $pinned A list of pinned award IDs (null: all awards)
 * @param  ?array $allowed_sorts List of allowed sorts (null: auto-detected for content type)
 * @param  ?MEMBER $member_id Member ID to run as (null: current member)
 * @param  array $infos Info maps for content types, if not passed will be looked up
 * @return array A pair: Rows, Max count
 */
function content_rows_for_multi_type(array $content_types, ?int $days = null, $extra_where = '', $extra_join = '', string $sort = 'title ASC', int $start = 0, ?int $max = null, $select = '', $select_b = '', $filter = '', bool $check_perms = true, ?array $pinned = [], ?array $allowed_sorts = null, ?int $member_id = null, array $infos = []) : array
{
    $combined_rows = [];
    $pinned_rows = [];
    $combined_max_rows = 0;

    $current_time = time();

    $sort_order_field_missing = false;
    $sort_strict_error = (count($content_types) == 1); // Only throw hack attacks on sorting if we provided one content type; otherwise we need some tolerance as specified sorts might not apply to all types

    foreach ($content_types as $content_type) {
        if (array_key_exists($content_type, $infos)) {
            $info = $infos[$content_type];
        } else {
            $object = get_content_object($content_type);
            $info = $object->info();
        }

        $_start = 0;
        $_max = ($max === null) ? null : ($max + $start); // This is so we can sort the first $max results against those of other content types to get a consistent combined sort order
        $_select = is_array($select) ? (array_key_exists($content_type, $select) ? $select[$content_type] : '') : $select;
        $_select_b = is_array($select_b) ? (array_key_exists($content_type, $select_b) ? $select_b[$content_type] : '') : $select_b;
        $_filter = is_array($filter) ? (array_key_exists($content_type, $filter) ? $filter[$content_type] : '') : $filter;
        $_extra_where = is_array($extra_where) ? (array_key_exists($content_type, $extra_where) ? $extra_where[$content_type] : '') : $extra_where;
        $_extra_join = is_array($extra_join) ? (array_key_exists($content_type, $extra_join) ? $extra_join[$content_type] : '') : $extra_join;
        list($rows, $max_rows, $pinned_rows) = content_rows_for_type($content_type, $days, $_extra_where, $_extra_join, $sort, $_start, $_max, $_select, $_select_b, $_filter, $check_perms, $pinned, $allowed_sorts, $member_id, $info, $sort_strict_error);
        foreach ($rows as $row) {
            $row['content_type'] = $content_type;

            if (array_key_exists('sort_order', $row)) { // Random sort order won't carry through due to limitation of DISTINCT queries
                $sort_order = $row['sort_order'];
                if ($sort == 'prominence') {
                    if ($sort_order === null) { // Likely sorting by maximum date of entries but there are no entries
                        $sort_order = PHP_INT_MAX;
                    } elseif (is_integer($sort_order)) {
                        $sort_order = intval(floatval(abs(time() - $sort_order)) / floatval($info['default_prominence_weight']));
                        if (($info['default_prominence_flags'] & PROMINENCE_FLAG_PINNED) != 0) {
                            $sort_order = PHP_INT_MIN ;
                        }
                    }

                    $row['sort_order'] = $sort_order;
                }
            } else {
                $sort_order_field_missing = true;
            }

            $combined_rows[] = $row;
        }
        foreach ($pinned_rows as $i => $row) {
            $row['content_type'] = $content_type;
            $pinned_rows[$i] = $row;
        }
        $combined_max_rows += $max_rows;
    }

    ksort($pinned_rows);

    if (!$sort_order_field_missing) {
        list($url_sort, $dir) = read_abstract_sorting_params($sort, $allowed_sorts);
        if ($sort == 'prominence') { // We flipped the ordering numbers about and this inverted the sequencing
            $dir = ($dir == 'DESC') ? 'ASC' : 'DESC';
        }
        sort_maps_by($combined_rows, ($dir == 'DESC') ? '!sort_order' : 'sort_order', false, ($url_sort == 'title'));
    }

    $final_rows = array_slice(array_merge($pinned_rows, $combined_rows), $start, $max);

    return [$final_rows, $combined_max_rows];
}

/**
 * Get content rows matching certain parameters.
 *
 * @param  ID_TEXT $content_type Content type to get records for
 * @param  ?integer $days Day limit for recency (null: no limit)
 * @param  string $extra_where Extra WHERE SQL
 * @param  string $extra_join Extra JOIN SQL
 * @param  string $_url_sort URL-style sort parameter
 * @param  integer $start Start offset
 * @param  ?integer $max Maximum results to return; pinned rows do not count towards it (null: no limit)
 * @param  string $select Selectcode
 * @param  string $select_b Selectcode for secondary category
 * @param  string $filter Filtercode
 * @param  boolean $check_perms Whether to check permissions
 * @param  ?array $pinned A list of pinned award IDs (null: all awards)
 * @param  ?array $allowed_sorts List of allowed sorts (null: auto-detected for content type)
 * @param  ?MEMBER $member_id Member ID to run as (null: current member)
 * @param  ?array $info Info map for content type (null: look up)
 * @param  boolean $sort_strict_error Throw a hack attack if the provided $_url_sort is not allowed
 * @return array A tuple: Rows, Max count, Pinned rows
 */
function content_rows_for_type(string $content_type, ?int $days, string $extra_where, string $extra_join, string $_url_sort, int $start, ?int $max, string $select = '', string $select_b = '', string $filter = '', bool $check_perms = true, ?array $pinned = [], ?array $allowed_sorts = null, ?int $member_id = null, ?array $info = null, bool $sort_strict_error = true) : array
{
    require_code('content');

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

    // Fix invalid parameters
    if (($max !== null) && ($max < 1)) {
        $max = 1;
    }

    // Read content object
    if ($info === null) {
        $object = get_content_object($content_type);
        $info = $object->info();
    }
    if ($info === null) {
        warn_exit(do_lang_tempcode('NO_SUCH_CONTENT_TYPE', escape_html($content_type)));
    }
    $first_id_field = is_array($info['id_field']) ? $info['id_field'][0] : $info['id_field'];

    // Special clauses for content type
    if (array_key_exists('extra_where_sql', $info)) {
        $extra_where .= ' AND ' . $info['extra_where_sql'];
    }

    if (($_url_sort == 'prominence') && (($info['default_prominence_flags'] & PROMINENCE_FLAG_ACTIVE_ONLY) != 0)) {
        $extra_where .= ' AND ' . $info['active_only_extra_where_sql'];
    }

    // Permissions check
    $permission_module_access = null;
    $permission_module_select = null;
    if (is_array($info['category_field'])) {
        $category_field_access = $info['category_field'][0];
        $category_field_select = $info['category_field'][1];
    } else {
        $category_field_access = $info['category_field'];
        $category_field_select = $info['category_field'];
    }
    if (array_key_exists('permission_module', $info)) {
        if (is_array($info['permission_module'])) {
            $permission_module_access = $info['permission_module'][0];
            $permission_module_select = $info['permission_module'][1];
        } else {
            $permission_module_access = $info['permission_module'];
            $permission_module_select = $info['permission_module'];
        }
    }
    // Actually for categories we check access on category ID
    if ($info['is_category'] && $permission_module_access !== null) {
        $category_field_access = $first_id_field;
    }
    if ((!$GLOBALS['FORUM_DRIVER']->is_super_admin($member_id)) && ($check_perms)) {
        if (addon_installed('content_privacy')) {
            require_code('content_privacy');
            list($privacy_join, $privacy_where) = get_privacy_where_clause($content_type, 'r', $member_id);
            $extra_join .= $privacy_join;
            $extra_where .= $privacy_where;
        }

        $groups = get_permission_where_clause_groups($member_id, true, 'a.');
        if ($category_field_access !== null) {
            if ($content_type == 'zone') {
                $extra_where .= get_zone_permission_where_clause($category_field_access, $member_id, $groups);
            } elseif ($content_type === 'comcode_page') {
                $extra_where .= get_page_permission_where_clause($category_field_access, $category_field_select, $member_id, $groups);
            } elseif ($permission_module_access !== null) {
                $extra_where .= get_category_permission_where_clause($permission_module_access, $category_field_access, $member_id, $groups);
            }
        }
        if (($category_field_select !== null) && ($category_field_select != $category_field_access) && ($permission_module_select !== null)) {
            $extra_where .= get_category_permission_where_clause($permission_module_select, $category_field_select, $member_id, $groups);
        }
    }

    // Selectcode support
    if (($select != '') && ($category_field_select !== null)) {
        if ($select == '-1') {
            return [[], 0, []]; // Optimisation: Will never match
        }

        $selectcode_extra_where = build_selectcode_select_for_content_type($select, $info, $category_field_select);
        $parent_spec__table_name = array_key_exists('parent_spec__table_name', $info) ? $info['parent_spec__table_name'] : $info['table'];
        if (($parent_spec__table_name !== null) && ($parent_spec__table_name != $info['table'])) {
            $extra_join .= ' LEFT JOIN ' . $info['db']->get_table_prefix() . $parent_spec__table_name . ' parent ON parent.' . $info['parent_spec__field_name'] . '=r.' . $first_id_field;
        }
        if ($selectcode_extra_where != '') {
            if ($selectcode_extra_where == '1=0' || $selectcode_extra_where == '0=1') {
                return [[], 0, []]; // Optimisation: Will never match
            }
            $extra_where .= ' AND (' . $selectcode_extra_where . ')';
        }
    }
    if (($select_b != '') && ($category_field_access !== null)) {
        if ($select_b == '-1') {
            return [[], 0, []]; // Optimisation: Will never match
        }

        $selectcode_extra_where = build_selectcode_select_for_content_type($select_b, $info, $category_field_access);
        if ($selectcode_extra_where != '') {
            if ($selectcode_extra_where == '1=0' || $selectcode_extra_where == '0=1') {
                return [[], 0, []]; // Optimisation: Will never match
            }
            $extra_where .= ' AND (' . $selectcode_extra_where . ')';
        }
    }

    // Filtercode support
    if ($filter != '') {
        global $BLOCK_CORE_DEVELOPER_ERROR_EMAILS;
        $BLOCK_CORE_DEVELOPER_ERROR_EMAILS = true;

        // Convert the filters to SQL
        require_code('filtercode');
        list($filtercode_extra_join, $filtercode_extra_where) = filtercode_to_sql($info['db'], parse_filtercode($filter), $content_type);
        $extra_join .= implode('', $filtercode_extra_join);
        $extra_where .= $filtercode_extra_where;
    }

    // Region filter
    if (get_option('filter_regions') == '1') {
        require_code('locations');
        $extra_where .= sql_region_filter($content_type, 'r.' . $first_id_field);
    }

    // Validation check
    if ((array_key_exists('validated_field', $info)) && (addon_installed('validation')) && ($info['validated_field'] != '') && (!has_privilege($member_id, 'see_not_validated'))) {
        $extra_where .= ' AND r.' . $info['validated_field'] . '=1';
    }

    // Time range
    if (($days !== null) && ($info['date_field'] !== null)) {
        $extra_where .= ' AND r.' . $info['date_field'] . '>=' . strval(time() - 60 * 60 * 24 * $days);
    }

    // Find requested pinned awards
    $pinned_rows = [];
    if (($pinned !== []) && (addon_installed('awards'))) {
        $pinned_where = '';
        if ($pinned === null) {
            $pinned_where = db_string_equal_to('a_content_type', $content_type);
        } else {
            foreach ($pinned as $award_id) {
                if ($pinned_where != '') {
                    $pinned_where .= ' OR ';
                }
                $pinned_where .= 'a_type_id=' . strval($award_id);
            }
            $pinned_where = '(' . db_string_equal_to('a_content_type', $content_type) . ') AND ' . $pinned_where;
        }

        $award_sql = 'SELECT a.a_type_id,a.content_id FROM ' . get_table_prefix() . 'award_types t';
        $award_sql .= $info['db']->singular_join('award_archive', 'a', 'a_type_id=t.id', 'date_and_time', 'MAX', 'JOIN');
        $award_sql .= ' WHERE ' . $pinned_where;
        $awarded_content_ids = collapse_2d_complexity('a_type_id', 'content_id', $GLOBALS['SITE_DB']->query($award_sql, null, 0, false, true));

        if ($pinned === null) {
            $pinned = array_keys($awarded_content_ids);
        }

        foreach ($pinned as $i => $award_id) { // We iterate $pinned to preserve order
            if (!isset($awarded_content_ids[$award_id])) {
                continue;
            }
            $awarded_content_id = $awarded_content_ids[$award_id];
            $award_content_row = content_get_row($awarded_content_id, $info);
            if ($award_content_row !== null) {
                if (is_integer($awarded_content_id)) {
                    $extra_where .= ' AND r.' . $first_id_field . '<>' . strval($awarded_content_id);
                } else {
                    $extra_where .= ' AND r.' . db_string_not_equal_to($first_id_field, $awarded_content_id);
                }
                if ((!addon_installed('validation')) || (!isset($info['validated_field'])) || ($award_content_row[$info['validated_field']] != 0)) {
                    $pinned_rows[$i] = $award_content_row;
                }
            }
        }
    }

    // Put query together
    $lang_fields = find_lang_fields($info['table'], 'r');
    $query = ' FROM ' . get_table_prefix() . $info['table'] . ' r' . $extra_join . ' WHERE 1=1' . $extra_where;
    list($sql_sort, $dir, $url_sort) = handle_abstract_sorting($_url_sort, $info, null, $sort_strict_error);

    // Run queries
    $max_rows = $info['db']->query_value_if_there('SELECT COUNT(DISTINCT r.' . $first_id_field . ') ' . $query, false, true);
    if ($max === 0) {
        $rows = []; // Optimisation
    } else {
        $full_query = 'SELECT DISTINCT r.*';
        /*
        if ($_url_sort != 'random') {
            $full_query .= ',' . $sql_sort . ' AS sort_order';
        }
        */
        $full_query .= $query . ' ORDER BY ' . $sql_sort;
        $rows = $info['db']->query($full_query, $max, $start, false, true, $lang_fields);
    }
    return [$rows, $max_rows + count($pinned_rows), $pinned_rows];
}

/**
 * Make a selectcode SQL fragment.
 *
 * @param  string $select The select string
 * @param  array $info Map of details of our content type
 * @param  ?string $category_field_select The field name of the category to select against (null: work it out)
 * @return string SQL fragment
 */
function build_selectcode_select_for_content_type(string $select, array $info, ?string $category_field_select = null) : string
{
    if ($category_field_select === null) {
        if (is_array($info['category_field'])) {
            $category_field_select = $info['category_field'][1];
        } else {
            $category_field_select = $info['category_field'];
        }
    }

    $parent_spec__table_name = array_key_exists('parent_spec__table_name', $info) ? $info['parent_spec__table_name'] : $info['table'];
    $parent_field_name = $info['is_category'] ? (is_array($info['id_field']) ? implode(',', $info['id_field']) : $info['id_field']) : $category_field_select;
    if ($parent_field_name === null) {
        $parent_spec__table_name = null;
    }
    $parent_spec__parent_name = array_key_exists('parent_spec__parent_name', $info) ? $info['parent_spec__parent_name'] : null;
    $parent_spec__field_name = array_key_exists('parent_spec__field_name', $info) ? $info['parent_spec__field_name'] : null;
    $id_field_numeric = ((!array_key_exists('id_field_numeric', $info)) || ($info['id_field_numeric']));
    $category_is_string = ((array_key_exists('category_is_string', $info)) && (is_array($info['category_is_string']) ? $info['category_is_string'][1] : $info['category_is_string']));

    require_code('selectcode');
    return selectcode_to_sqlfragment($select, 'r.' . (is_array($info['id_field']) ? $info['id_field'][0] : $info['id_field']), $parent_spec__table_name, $parent_spec__parent_name, 'r.' . $parent_field_name, $parent_spec__field_name, $id_field_numeric, !$category_is_string);
}

/**
 * Remap a simple URL-style sort string with something SQL-compatible. Recognising rating sort order only, but does also support breaking the string down.
 *
 * @param  string $url_sort The URL sort string
 * @param  array $info Map of details of our content type
 * @param  ?array $allowed_sorts List of allowed sort types (null: default set)
 * @param  boolean $strict_error Provide a hack-attack error on invalid input
 * @return array A tuple: The SQL-style sort order, The sort direction, The URL-style sort order
 */
function handle_abstract_sorting(string $url_sort, array $info, ?array $allowed_sorts = null, bool $strict_error = true) : array
{
    $feedback_type = isset($info['feedback_type_code']) ? $info['feedback_type_code'] : null;
    $first_id_field = is_array($info['id_field']) ? $info['id_field'][0] : $info['id_field'];

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

        $allowed_sorts[] = 'id';
        $allowed_sorts[] = $first_id_field; // Allow sorting by ID (first field)

        if (isset($info['order_field'])) {
            $allowed_sorts[] = 'natural';
            $allowed_sorts[] = $info['order_field']; // Also allow by direct column name
        }

        if ($info['add_time_field'] !== null) {
            $allowed_sorts[] = 'recent';
            $allowed_sorts[] = $info['add_time_field']; // Also allow by direct column name
        }

        if ($info['edit_time_field'] !== null) {
            $allowed_sorts[] = 'updated';
            $allowed_sorts[] = $info['edit_time_field']; // Also allow by direct column name
        }

        if ($info['parent_category_field'] !== null) {
            $allowed_sorts[] = 'category';
            $allowed_sorts[] = $info['parent_category_field']; // Also allow by direct column name
        }

        if ((isset($info['prominence_custom_sort'])) || ($info['add_time_field'] !== null)) {
            $allowed_sorts[] = 'prominence';
        }

        if ((isset($info['title_field'])) && (!is_array($info['title_field'])) && (strpos($info['title_field'], ':') === false)) {
            $allowed_sorts[] = 'title';
            $allowed_sorts[] = $info['title_field']; // Also allow by direct column name (should not directly use dereferenced field)
        }

        $allowed_sorts = array_merge($allowed_sorts, [
            'random',
            'fixed_random',
        ]);

        if (isset($info['views_field'])) {
            $allowed_sorts[] = 'views';
            $allowed_sorts[] = $info['views_field']; // Also allow by direct column name
        }

        if (isset($info['submitter_field'])) {
            $allowed_sorts[] = 'submitter';
            $allowed_sorts[] = $info['submitter_field']; // Also allow by direct column name
        }

        if (isset($info['additional_sort_fields'])) {
            $allowed_sorts = array_merge($allowed_sorts, array_keys($info['additional_sort_fields']));
        }

        if (isset($info['validated_field']) && addon_installed('validation')) {
            $allowed_sorts[] = 'validated';
            $allowed_sorts[] = $info['validated_field']; // Also allow by direct column name
        }

        if (isset($info['author_field']) && addon_installed('authors')) {
            $allowed_sorts[] = 'author';
            $allowed_sorts[] = $info['author_field']; // Also allow by direct column name
        }

        if ($info['image_field'] !== null) {
            $allowed_sorts[] = 'image';
            $allowed_sorts[] = $info['image_field']; // Also allow by direct column name
        }

        if ($feedback_type !== null) {
            $allowed_sorts = array_merge($allowed_sorts, [
                'average_rating',
                'compound_rating',
            ]);
        }
    }

    list($url_sort, $dir) = read_abstract_sorting_params($url_sort, $allowed_sorts, $strict_error);
    $append_dir = true;

    if ((is_array($info['additional_sort_fields'])) && (isset($info['additional_sort_fields'][$url_sort])) && ($info['additional_sort_fields'][$url_sort] !== null)) { // Custom SQL specified for this sort
        if (is_array($info['additional_sort_fields'][$url_sort])) { // Different SQL specified for ASC versus DESC
            $sql_sort = $info['additional_sort_fields'][$url_sort][$dir];
            $append_dir = false; // In this case, we always explicitly define ASC and DESC in the custom SQL
        } else {
            $sql_sort = $info['additional_sort_fields'][$url_sort];
        }
    } elseif ($url_sort == 'id') {
        $sql_sort = 'r.' . $first_id_field;
    } elseif ($url_sort == 'recent') {
        $sql_sort = 'r.' . $info['add_time_field'];
    } elseif ($url_sort == 'updated') {
        $sql_sort = 'r.' . $info['edit_time_field'];
    } elseif ($url_sort == 'prominence') {
        if (isset($info['prominence_custom_sort'])) {
            $sql_sort = $info['prominence_custom_sort'];
            $dir = $info['prominence_custom_sort_dir'];
        } else {
            $sql_sort = 'r.' . $info['add_time_field'];
        }
    } elseif ((isset($info['title_field'])) && (($url_sort == 'title') || ($url_sort == $info['title_field']))) {
        if ($info['title_field_dereference']) {
            $sql_sort = $GLOBALS['SITE_DB']->translate_field_ref($info['title_field']);
        } else {
            $sql_sort = 'r.' . $info['title_field'];
        }
    } elseif ($url_sort == 'natural') {
        $sql_sort = 'r.' . $info['order_field'];
    } elseif ($url_sort == 'views') {
        $sql_sort = 'r.' . $info['views_field'];
    } elseif ($url_sort == 'validated') {
        $sql_sort = 'r.' . $info['validated_field'];
    } elseif ($url_sort == 'category') {
        $sql_sort = 'r.' . $info['parent_category_field'];
    } elseif ($url_sort == 'image') {
        $sql_sort = 'r.' . $info['image_field'];
    } elseif ($url_sort == 'submitter') {
        $sql_sort = 'r.' . $info['submitter_field']; // TODO: find a way we can sort by username instead of member ID; will be complicated given forum drivers
    } elseif ($url_sort == 'author') {
        $sql_sort = 'r.' . $info['author_field'];
    } elseif ($url_sort == 'average_rating') {
        $sql_sort = '(SELECT AVG(rating) FROM ' . get_table_prefix() . 'rating WHERE ' . db_string_equal_to('rating_for_type', $feedback_type) . ' AND rating_for_id=r.' . $first_id_field . ')';
    } elseif ($url_sort == 'compound_rating') {
        $sql_sort = '(SELECT SUM(rating) FROM ' . get_table_prefix() . 'rating WHERE ' . db_string_equal_to('rating_for_type', $feedback_type) . ' AND rating_for_id=r.' . $first_id_field . ')';
    } elseif ($url_sort == 'random') {
        $sql_sort = '(' . db_function('RAND') . ')';
    } elseif ($url_sort == 'fixed_random') {
        if ($info['id_field_numeric']) {
            $sql_sort = '(' . db_function('MOD', [$first_id_field, date('d')]) . ')';
        } else {
            $sql_sort = '(' . db_function('CONCAT', [db_function('MD5', [$first_id_field]), '\'' . db_escape_string(date('d')) . '\'']) . ')';
        }
    } else {
        $sql_sort = 'r.' . $url_sort;
    }

    if ($append_dir) {
        $sql_sort .= ' ' . $dir;
    }

    return [$sql_sort, $dir, $url_sort];
}

/**
 * Clean up and verify URL sort parameters.
 *
 * @param  string $_url_sort The URL sort string
 * @param  ?array $allowed_sorts List of allowed sort types (null: don't check)
 * @param  boolean $strict_error Provide a hack-attack error on invalid input
 * @return array A pair: The URL-style sort order, The URL-style sort direction
 */
function read_abstract_sorting_params(string $_url_sort, ?array $allowed_sorts, bool $strict_error = true) : array
{
    $banal_default_sorts = [
        'id',
        'natural',
        'recent',
        'updated',
        'prominence',
        'title',
        'random',
        'fixed_random',
        'views',
        'average_rating',
        'compound_rating',
        'validated',
        'category',
        'image',
        'submitter',
        'author',
    ];

    $parts = explode(' ', $_url_sort, 2);
    if (count($parts) == 1) {
        $parts[] = 'DESC';
    }
    list($url_sort, $dir) = $parts;
    if (($allowed_sorts !== null) && (!in_array($url_sort, $allowed_sorts))) {
        if (($strict_error) && (!in_array($url_sort, $banal_default_sorts))) {
            log_hack_attack_and_exit('ORDERBY_HACK');
        }

        // Determine a banal fall-back we can use
        if (in_array('natural', $allowed_sorts)) {
            $url_sort = 'natural';
            $dir = 'ASC';
        } elseif (in_array('title', $allowed_sorts)) {
            $url_sort = 'title';
            $dir = 'ASC';
        } elseif (in_array('prominence', $allowed_sorts)) {
            $url_sort = 'prominence';
            $dir = 'DESC';
        } elseif (in_array('recent', $allowed_sorts)) {
            $url_sort = 'recent';
            $dir = 'DESC';
        } else {
            $url_sort = 'fixed_random';
            $dir = 'ASC';
        }
    }
    if (!in_array($dir, ['ASC', 'DESC'])) {
        if ($strict_error) {
            log_hack_attack_and_exit('ORDERBY_HACK');
        }
        $dir = 'ASC';
    }
    return [$url_sort, $dir];
}

/**
 * Base class for content hooks.
 *
 * @package    core_notifications
 */
abstract class Hook_CMA
{
    /**
     * Get content type details.
     *
     * @param  ?ID_TEXT $zone The zone to link through to (null: autodetect)
     * @param  boolean $get_extended_data Populate additional data that is somewhat costly to compute (add_url, archive_url)
     * @return ?array Map of content-type info (null: disabled)
     */
    abstract public function info(?string $zone = null, bool $get_extended_data = false) : ?array;


    /**
     * Get content type details, with caching.
     *
     * @return ?array Map of content-type info (null: disabled)
     */
    public function info_basic_cached() : ?array
    {
        static $info = [];
        $key = get_class($this);
        if (!isset($info[$key])) {
            $info[$key] = $this->info();
        }
        return $info[$key];
    }

    /**
     * Get a content row.
     *
     * @param  ID_TEXT $content_id The content ID
     * @return ?array The row (null: not found)
     */
    public function get_row(string $content_id) : ?array
    {
        $info = $this->info_basic_cached();
        return content_get_row($content_id, $info);
    }

    /**
     * Get the content type this object is handling.
     *
     * @return string The content type
     */
    public function get_content_type() : string
    {
        return preg_replace('#^Hook_(content|resource)_meta_aware_#', '', get_class($this));
    }

    /**
     * Get the label for this content type.
     *
     * @param  ?array $row The database row for the content (null: no override support)
     * @return Tempcode The label
     */
    public function get_content_type_label(?array $row = null) : object
    {
        $info = $this->info_basic_cached();
        if ($row !== null) {
            if (!empty($info['content_type_label_override'])) {
                $field = $info['content_type_label_override'];
                if (strpos($field, 'CALL:') !== false) {
                    return call_user_func(trim(substr($field, 5)), $row);
                }
            }
        }
        return do_lang_tempcode($info['content_type_label']);
    }

    /**
     * Get the universal label for this content type.
     *
     * @param  ?array $row The database row for the content (null: no override support)
     * @return string The universal label
     */
    public function get_content_type_universal_label(?array $row = null) : string
    {
        $info = $this->info_basic_cached();
        if ($row !== null) {
            if (!empty($info['content_type_universal_label_override'])) {
                $field = $info['content_type_universal_label_override'];
                if (strpos($field, 'CALL:') !== false) {
                    return call_user_func(trim(substr($field, 5)), $row);
                }
            }
        }
        return $info['content_type_universal_label'];
    }

    /**
     * Get an ID for a content row. If there are multiple IDs, then they are colon-separated.
     *
     * @param  array $row The database row for the content
     * @return string ID
     */
    public function get_id(array $row) : string
    {
        $info = $this->info_basic_cached();
        return extract_content_str_id_from_data($row, $info);
    }

    /**
     * Get an ID string for a content row. This is not to be used anywhere as an ID, it is designed to be human-readable.
     *
     * @param  array $row The database row for the content
     * @param  integer $render_type A FIELD_RENDER_* constant
     * @return mixed ID string (string or Tempcode, depending on $render_type)
     */
    public function get_id_string(array $row, int $render_type = 1)
    {
        $info = $this->info_basic_cached();

        $ret_arr = [];
        $id_field = is_array($info['id_field']) ? $info['id_field'] : [$info['id_field']];
        $id_field = array_reverse($id_field);
        foreach ($id_field as $_id_field) {
            $_ret = $row[$_id_field];
            if (is_integer($_ret)) {
                $_ret = '#' . strval($_ret);
            }
            $ret_arr[] = $_ret;
        }
        $ret = implode(', ', $ret_arr);

        switch ($render_type) {
            case FIELD_RENDER_COMCODE:
                return comcode_escape($ret);

            case FIELD_RENDER_HTML:
                return make_string_tempcode(escape_html($ret));
        }

        // FIELD_RENDER_PLAIN:
        return $ret;
    }

    /**
     * Get content title of a content row.
     *
     * @param  array $row The database row for the content
     * @param  integer $render_type A FIELD_RENDER_* constant
     * @param  boolean $falled_back_to_id Whether this has had to fall back to an ID due to missing title (returned by reference)
     * @param  boolean $resource_fs_style Whether to use the content API as resource-fs requires (may be slightly different)
     * @return mixed Content title (string or Tempcode, depending on $render_type)
     */
    public function get_title(array $row, int $render_type = 1, bool &$falled_back_to_id = false, bool $resource_fs_style = false)
    {
        $info = $this->info_basic_cached();

        $falled_back_to_id = false;

        $title_field = $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
        }
        $dereference = $info['title_field_dereference'];
        $supports_comcode = array_key_exists('title_field_supports_comcode', $info) ? $info['title_field_supports_comcode'] : false;
        if (($resource_fs_style) && (array_key_exists('title_field__resource_fs', $info))) {
            $title_field = $info['title_field__resource_fs'];
            $dereference = $info['title_field_dereference__resource_fs'];
        }

        if ($title_field === null) {
            $falled_back_to_id = true;
            return $this->get_id_string($row, $render_type);
        }

        $ret = $this->get_textual_field($row, $render_type, $info, $title_field, $dereference, $supports_comcode, $resource_fs_style);

        if (cms_empty_safe($ret)) {
            $falled_back_to_id = true;
            return $this->get_id_string($row, $render_type);
        }

        return $ret;
    }

    /**
     * Get content description of a content row.
     *
     * @param  array $row The database row for the content
     * @param  integer $render_type A FIELD_RENDER_* constant
     * @return mixed Content description (string or Tempcode, depending on $render_type)
     */
    public function get_description(array $row, int $render_type = 1)
    {
        $info = $this->info_basic_cached();

        $description_field = $info['description_field'];
        if (is_array($description_field)) {
            $description_field = array_pop($description_field); // Anything ahead is just stuff we need to preload for the "CALL:" to work
        }
        $dereference = $info['description_field_dereference'];
        $supports_comcode = array_key_exists('description_field_supports_comcode', $info) ? $info['description_field_supports_comcode'] : false;

        if ($description_field === null) {
            if ($render_type == FIELD_RENDER_HTML) {
                return new Tempcode();
            } else {
                return '';
            }
        }

        $ret = $this->get_textual_field($row, $render_type, $info, $description_field, $dereference, $supports_comcode);

        if ($ret === null) {
            if ($render_type == FIELD_RENDER_HTML) {
                return new Tempcode();
            } else {
                return '';
            }
        }

        return $ret;
    }

    /**
     * Get particular textual information from a content row.
     *
     * @param  array $row The database row for the content
     * @param  integer $render_type A FIELD_RENDER_* constant
     * @param  array $info The info map for the content type
     * @param  string $field The field name for what we are looking up
     * @param  boolean $dereference Whether we need to dereference the field as a language string
     * @param  boolean $supports_comcode Whether the field supports Comcode
     * @param  boolean $resource_fs_style Whether to use the content API as resource-fs requires (may be slightly different)
     * @return ?mixed Content title (string or Tempcode, depending on $render_type) (null: could not generate)
     */
    protected function get_textual_field(array $row, int $render_type, array $info, string $field, bool $dereference, bool $supports_comcode, bool $resource_fs_style = false)
    {
        if (strpos($field, 'CALL:') !== false) {
            return call_user_func(trim(substr($field, 5)), $row, $render_type, $resource_fs_style);
        }

        $id_field = is_array($info['id_field']) ? $info['id_field'] : [$info['id_field']];

        $ret = mixed();

        switch ($render_type) {
            case FIELD_RENDER_PLAIN:
                if ($row[$field] === null) {
                    return '';
                }

                if ($dereference) {
                    $ret = get_translated_text($row[$field], $info['db']);
                } else {
                    $ret = $row[$field];
                }

                if ($supports_comcode) {
                    require_code('comcode');
                    $ret = strip_comcode($ret);
                }
                break;

            case FIELD_RENDER_COMCODE:
                if ($row[$field] === null) {
                    return '';
                }

                if ($dereference) {
                    $ret = get_translated_text($row[$field], $info['db']);
                } else {
                    $ret = $row[$field];
                }

                if (!$supports_comcode) {
                    require_code('comcode');
                    $ret = comcode_escape($ret);
                }
                break;

            case FIELD_RENDER_HTML:
                if ($row[$field] === null) {
                    return new Tempcode();
                }

                if ($dereference) {
                    if ($supports_comcode) {
                        $just_row = db_map_restrict($row, array_merge($id_field, [$field]));
                        $ret = get_translated_tempcode($info['table'], $just_row, $field, $info['db']);
                    } else {
                        $ret = get_translated_text($row[$field], $info['db']);
                        $ret = make_string_tempcode(escape_html($ret));
                    }
                } else {
                    $ret = $row[$field];

                    if ($supports_comcode) {
                        $ret = comcode_to_tempcode($ret, isset($info['submitter_field']) ? $row[$info['submitter_field']] : null);
                    } else {
                        $ret = make_string_tempcode(escape_html($ret));
                    }
                }

                break;
        }

        return $ret;
    }

    /**
     * Get content image URL for a content row.
     *
     * @param  array $row The database row for the content
     * @param  integer $fallback_method Whether to provide a default image if there is no real image, a IMAGE_URL_FALLBACK_* constant
     * @param  boolean $has_fallen_back Returned by reference, set if the image returned is a fallback image
     * @return URLPATH Image URL (blank: none)
     */
    public function get_image_url(array $row, int $fallback_method = 0, bool &$has_fallen_back = false) : string
    {
        $info = $this->info_basic_cached();

        switch ($fallback_method) {
            case IMAGE_URL_FALLBACK_NONE:
                $fallback_theme_image = null;
                break;

            case IMAGE_URL_FALLBACK_SOFT:
                $fallback_theme_image = isset($info['alternate_icon_theme_image']) ? $info['alternate_icon_theme_image'] : null;
                break;

            case IMAGE_URL_FALLBACK_HARD:
                $fallback_theme_image = isset($info['alternate_icon_theme_image']) ? $info['alternate_icon_theme_image'] : 'icons/no_image';
                break;
        }

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

        if (($field === null) && ($info['support_custom_fields']) && (addon_installed('catalogues'))) {
            require_code('fields');
            require_code('hooks/systems/content_meta_aware/catalogue_entry');
            $content_type = $this->get_content_type();
            $catalogue_entry_id = get_bound_content_entry($content_type, $this->get_id($row));
            if ($catalogue_entry_id !== null) {
                $field = 'CALL: generate_catalogue_entry_image_url';
                $row = ['id' => $catalogue_entry_id, 'c_name' => '_' . $content_type];
            }
        }

        if ($field === null) {
            if ($fallback_theme_image !== null) {
                $has_fallen_back = true;
                return find_theme_image($fallback_theme_image);
            }

            return '';
        }

        if (strpos($field, 'CALL:') !== false) {
            $ret = call_user_func(trim(substr($field, 5)), $row);

            if (($ret == '') && ($fallback_theme_image !== null)) {
                $has_fallen_back = true;
                return find_theme_image($fallback_theme_image);
            }

            return $ret;
        }

        $ret = $row[$field];
        if ($ret == '') {
            if ($fallback_theme_image !== null) {
                $has_fallen_back = true;
                return find_theme_image($fallback_theme_image);
            }

            return '';
        }

        if ($info['image_field_is_theme_image']) {
            return find_theme_image($ret);
        }

        if (url_is_local($ret)) {
            $ret = get_custom_base_url() . '/' . $ret;
        }

        return $ret;
    }

    /**
     * Get add timestamp for content.
     *
     * @param  array $row The database row for the content
     * @return ?TIME Add time (null: unknown)
     */
    public function get_add_time(array $row) : ?int
    {
        $info = $this->info_basic_cached();
        if ($info['add_time_field'] === null) {
            return null;
        }
        return $row[$info['add_time_field']];
    }

    /**
     * Get the label for the most relevant timestamp for content. What this actually is depends on the content.
     *
     * @return ?Tempcode Label (null: unknown)
     */
    public function get_most_relevant_time_label() : ?object
    {
        return do_lang_tempcode('DATE');
    }

    /**
     * Get the most relevant timestamp for content. What this actually is depends on the content.
     *
     * @param  array $row The database row for the content
     * @return ?TIME Add time (null: unknown)
     */
    public function get_most_relevant_time(array $row) : ?int
    {
        $info = $this->info_basic_cached();

        $field = $info['date_field'];
        if ($field === null) {
            $field = $info['add_time_field'];
        }

        if ($field === null) {
            return null;
        }
        return $row[$field];
    }

    /**
     * Get submitter for content.
     *
     * @param  array $row The database row for the content
     * @return ?MEMBER Submitter (null: unknown)
     */
    public function get_submitter(array $row) : ?int
    {
        $info = $this->info_basic_cached();
        if ($info['submitter_field'] === null) {
            return null;
        }
        return $row[$info['submitter_field']];
    }

    /**
     * Get username for content.
     *
     * @param  array $row The database row for the content
     * @return string Username (or message explaining lack of one)
     */
    public function get_username(array $row) : string
    {
        $submitter = $this->get_submitter($row);
        if ($submitter === null) {
            return do_lang('UNKNOWN');
        }
        return $GLOBALS['FORUM_DRIVER']->get_username($submitter, true);
    }

    /**
     * Get author for content.
     *
     * @param  array $row The database row for the content
     * @return ?string Author (null: unknown)
     */
    public function get_author(array $row) : ?string
    {
        if (!addon_installed('authors')) {
            return null;
        }

        $info = $this->info_basic_cached();
        if ($info['author_field'] === null) {
            return null;
        }
        return $row[$info['author_field']];
    }

    /**
     * Get a view page-link for content.
     *
     * @param  ?array $row The database row for the content (null: no ID, assume passed in some other way)
     * @param  string $append What to append to the view page-link
     * @return string The page-link (blank: none)
     */
    public function get_view_page_link(?array $row, string $append = '') : string
    {
        $info = $this->info_basic_cached();
        $pattern = $info['view_page_link_pattern'];
        if ($pattern === null) {
            return '';
        }
        $pattern .= $append;
        if ($row === null) {
            $page_link = str_replace(':_WILD', '', $pattern);
            $page_link = preg_replace('#:\w+=_WILD#', '', $page_link);
        } else {
            $page_link = str_replace('_WILD', $this->get_id($row), $pattern);
        }
        return $page_link;
    }

    /**
     * Get a view URL for content.
     *
     * @param  ?array $row The database row for the content (null: no ID, assume passed in some other way)
     * @param  boolean $skip_keep Whether to avoid keep_* parameters as it's going in an e-mail
     * @param  string $append What to append to the view page-link
     * @return Tempcode The URL (blank: none)
     */
    public function get_view_url(?array $row, bool $skip_keep = false, string $append = '') : object
    {
        $page_link = $this->get_view_page_link($row, $append);
        return page_link_to_tempcode_url($page_link, $skip_keep);
    }

    /**
     * Get an edit URL for content.
     *
     * @param  ?array $row The database row for the content (null: no ID, assume passed in some other way)
     * @param  boolean $skip_keep Whether to avoid keep_* parameters as it's going in an e-mail
     * @param  string $append What to append to the edit page-link
     * @return Tempcode The URL
     */
    public function get_edit_url(?array $row, bool $skip_keep = false, string $append = '') : object
    {
        $info = $this->info_basic_cached();
        $pattern = $info['edit_page_link_pattern'];
        if ($pattern === null) {
            return new Tempcode();
        }
        $pattern .= $append;
        if ($row === null) {
            $page_link = str_replace(':_WILD', '', $pattern);
            $page_link = preg_replace('#:\w+=_WILD#', '', $page_link);
        } else {
            $page_link = str_replace('_WILD', $this->get_id($row), $pattern);
        }
        return page_link_to_tempcode_url($page_link, $skip_keep);
    }

    /**
     * Get an add URL for content.
     *
     * @return Tempcode The URL
     */
    public function get_add_url() : object
    {
        $info = $this->info_basic_cached();
        if ($info['add_url'] === null) {
            return new Tempcode();
        }
        $url = page_link_to_tempcode_url($info['add_url']);
        return $url;
    }

    /**
     * Get am archive URL for content.
     *
     * @return Tempcode The URL
     */
    public function get_archive_url() : object
    {
        $info = $this->info_basic_cached();
        if ($info['archive_url'] === null) {
            return new Tempcode();
        }
        $url = page_link_to_tempcode_url($info['archive_url']);
        return $url;
    }

    /**
     * Render a content box for a content row.
     *
     * @param  array $row The database row for the content
     * @param  ID_TEXT $zone The zone to display in
     * @param  boolean $give_context Whether to include context (i.e. say WHAT this is, not just show the actual content)
     * @param  boolean $include_breadcrumbs Whether to include breadcrumbs (if there are any)
     * @param  ?ID_TEXT $root Virtual root to use (null: none)
     * @param  boolean $attach_to_url_filter Whether to copy through any filter parameters in the URL, under the basis that they are associated with what this box is browsing
     * @param  ID_TEXT $guid Overridden GUID to send to templates (blank: none)
     * @return Tempcode Results
     */
    public function render_box(array $row, string $zone, bool $give_context = true, bool $include_breadcrumbs = true, ?string $root = null, bool $attach_to_url_filter = false, string $guid = '') : object
    {
        $info = $this->info_basic_cached();

        $rep_image_url = $this->get_image_url($row);

        return do_template('SIMPLE_PREVIEW_BOX', [
            '_GUID' => ($guid == '') ? $this->get_content_type_universal_label($row) : $guid,
            'TITLE' => protect_from_escaping($this->get_title($row, FIELD_RENDER_HTML)),
            'SUMMARY' => protect_from_escaping($this->get_description($row, FIELD_RENDER_HTML)),
            'REP_IMAGE_URL' => $rep_image_url,
            'URL' => $this->get_view_url($row),
        ]);
    }

    /**
     * Render a hyperlink for a content row.
     *
     * @param  array $row The database row for the content
     * @return Tempcode Hyperlink
     */
    public function render_hyperlink(array $row) : object
    {
        $url = $this->get_view_url($row);
        $caption = $this->get_title($row, FIELD_RENDER_HTML);

        if ($url->is_empty()) {
            return $caption;
        }

        return hyperlink($url, $caption, false, false);
    }

    /**
     * Render a thumbnail for a content row.
     *
     * @param  array $row The database row for the content
     * @return Tempcode Hyperlink
     */
    public function render_thumbnail(array $row) : object
    {
        require_code('images');

        $img = $this->get_image_url($row, IMAGE_URL_FALLBACK_HARD);

        return do_image_thumb($img, $this->get_title($row), false);
    }

    /**
     * Render a hyperlink with thumbnail for a content row.
     *
     * @param  array $row The database row for the content
     * @return Tempcode Hyperlink
     */
    public function render_hyperlink_thumbnail(array $row) : object
    {
        require_code('images');

        $img = $this->get_image_url($row, IMAGE_URL_FALLBACK_HARD);

        $url = $this->get_view_url($row);
        $rep_image = do_image_thumb($img, $this->get_title($row), false);

        if ($url->is_empty()) {
            return $rep_image;
        }

        return hyperlink($url, $rep_image, false, false);
    }

    /**
     * Get headings of special relevant data this content type supports.
     *
     * @return array A map of heading codenames to Tempcode labels
     */
    public function get_special_keymap_headings() : array
    {
        $headings = [];
        return $headings;
    }

    /**
     * Get special relevant data this content type supports.
     *
     * @param  array $row Database row
     * @return array A map of heading codenames to Tempcode values
     */
    public function get_special_keymap(array $row) : array
    {
        $keymap = [];
        return $keymap;
    }

    /**
     * Find what keymap headings are available.
     *
     * @return array Tuple: Has author, Has submitter, Has date, Has ratings
     */
    protected function which_standard_keymap_headings() : array
    {
        $info = $this->info_basic_cached();

        $has_author = ($info['author_field'] !== null);
        $has_submitter = (!$has_author) && ($info['submitter_field'] !== null);
        $has_date = ($info['date_field'] !== null) || ($info['add_time_field'] !== null);
        $has_ratings = (get_option('is_on_rating') == '1') && ($info['feedback_type_code'] !== null);

        return [$has_author, $has_submitter, $has_date, $has_ratings];
    }

    /**
     * Get headings of standardised relevant data.
     *
     * @return array A map of heading codenames to Tempcode labels
     */
    public function get_standard_keymap_headings() : array
    {
        $headings = [];

        list($has_author, $has_submitter, $has_date, $has_ratings) = $this->which_standard_keymap_headings();

        $headings['title'] = do_lang_tempcode('TITLE');
        if ($has_author) {
            $headings['author'] = do_lang_tempcode('AUTHOR');
        }
        if ($has_submitter) {
            $headings['username'] = do_lang_tempcode('USERNAME');
        }
        if ($has_date) {
            $headings['time'] = $this->get_most_relevant_time_label();
        }
        if ($has_ratings) {
            $headings['rating'] = do_lang_tempcode('RATING');
        }

        return $headings;
    }

    /**
     * Get standardised relevant data.
     *
     * @param  array $row Database row
     * @return array A map of heading codenames to Tempcode values
     */
    public function get_standard_keymap(array $row) : array
    {
        $keymap = [];

        $info = $this->info_basic_cached();

        list($has_author, $has_submitter, $has_date, $has_ratings) = $this->which_standard_keymap_headings();

        $keymap['title'] = $this->render_hyperlink($row);
        if ($has_author) {
            $author = $this->get_author($row);
            $keymap['author'] = protect_from_escaping(escape_html(($author === null) ? do_lang('UNKNOWN') : $author));
        }
        if ($has_submitter) {
            $username = $this->get_username($row);
            $keymap['username'] = protect_from_escaping(escape_html($username));
        }
        if ($has_date) {
            $timestamp = $this->get_most_relevant_time($row);
            $keymap['time'] = ($timestamp === null) ? new Tempcode() : get_timezoned_date_tempcode($timestamp);
        }
        if ($has_ratings) {
            if ($info['feedback_type_code'] !== null) {
                require_code('feedback');
                $submitter = ($info['submitter_field'] === null) ? $GLOBALS['FORUM_DRIVER']->get_guest_id() : $row[$info['submitter_field']];
                $keymap['rating'] = display_rating($this->get_view_url($row), $this->get_title($row), $info['feedback_type_code'], $this->get_id($row), 'RATING_INLINE_STATIC', $submitter);
            } else {
                $keymap['rating'] = new Tempcode();
            }
        }

        return $keymap;
    }

    /**
     * Get an action language string for a particular content type based on a stub.
     * If it can't get a match it'll just use the stub.
     *
     * @param  string $string The language string stub, e.g. ADD (must itself be a valid language string)
     * @return Tempcode Tempcode of language string
     */
    public function content_language_string(string $string) : object
    {
        $info = $this->info_basic_cached();

        if ($info === null) {
            $test = null;
        } else {
            $regexp = $info['actionlog_regexp'];

            do_lang($info['content_type_label']); // This forces the language file to load if there is one, as it'll include the language file reference within content_type_label

            $string_custom = str_replace('\w+', $string, $regexp);
            $test = do_lang($string_custom, null, null, null, null, false);
        }

        if ($test === null) {
            $test = do_lang($string);
        }

        //return do_lang_tempcode($string_custom); // Assumes that the lang string stays memory resident, but our probing only guarantees it's resident NOW
        return protect_from_escaping($test); // But this should work as the string is rolled into the Tempcode permanently
    }

    /**
     * Create a selection list.
     *
     * @param  ?string $id The pre-selected ID (null: none selected)
     * @return Tempcode List
     */
    public function create_selection_list(?string $id = null) : object
    {
        $info = $this->info();

        $select = [];
        append_content_select_for_fields($select, $info, ['id', 'title']);

        if ($info['add_time_field'] === null) {
            $extra = '';
        } else {
            $extra = 'ORDER BY ' . $info['add_time_field'] . ' DESC';
        }

        $cnt = $info['db']->get_table_count_approx($info['table']);
        if (($cnt < intval(get_option('general_safety_listing_limit'))) && (is_string($info['title_field'])) && (strpos($info['title_field'], 'CALL:') !== false)) {
            $extra = 'ORDER BY ' . ($info['title_field_dereference'] ? $info['db']->translate_field_ref($info['title_field']) : $info['title_field']);
        }

        $rows = $info['db']->query_select($info['table'], $select, [], $extra, intval(get_option('general_safety_listing_limit')));
        $list = new Tempcode();
        foreach ($rows as $row) {
            $_id = $this->get_id($row);
            $_title = $this->get_title($row);
            $list->attach(form_input_list_entry($_id, $id == $_id, $_title));
        }

        return $list;
    }

    /**
     * Get the hook name of an AJAX tree selection list.
     *
     * @return ?string Hook name (null: none)
     */
    public function create_selection_tree_list() : ?string
    {
        return null;
    }
}
