<?php /*

 Composr
 Copyright (c) ocProducts, 2004-2016

 See text/EN/licence.txt for full licencing 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  ocProducts Ltd
 * @package    core_cns
 */

/**
 * Standard code module initialisation function.
 *
 * @ignore
 */
function init__forum__cns()
{
    global $LDAP_CONNECTION;
    $LDAP_CONNECTION = null;
    global $EMOTICON_LEVELS;
    $EMOTICON_LEVELS = null;
    global $FLOOD_CONTROL_ONCE;
    $FLOOD_CONTROL_ONCE = false;
    global $SENT_OUT_VALIDATE_NOTICE;
    $SENT_OUT_VALIDATE_NOTICE = false;
    global $LAST_POST_ID, $LAST_TOPIC_ID;
    $LAST_POST_ID = null;
    $LAST_TOPIC_ID = null;
    global $TOPIC_IDENTIFIERS_TO_IDS_CACHE, $FORUM_NAMES_TO_IDS_CACHE, $TOPIC_IS_THREADED_CACHE;
    $TOPIC_IDENTIFIERS_TO_IDS_CACHE = array();
    $FORUM_NAMES_TO_IDS_CACHE = array();
    $TOPIC_IS_THREADED_CACHE = array();
}

/**
 * Forum driver class.
 *
 * @package    core_forum_drivers
 */
class Forum_driver_cns extends Forum_driver_base
{
    /**
     * Initialise LDAP. To see if LDAP is running we check LDAP_CONNECTION for null. ldap_is_enabled is not good enough - we don't want Composr to bomb out under faulty LDAP settings, hence making it unfixable.
     */
    public function forum_layer_initialise()
    {
        /** A copy of the forum driver pointing to Conversr. This is used during importing as a permanent pointer to Conversr, even when FORUM_DRIVER may be pointing temporarily to an instance of the forum being imported.
         *
         * @global object $CNS_DRIVER
         */
        global $CNS_DRIVER;
        $CNS_DRIVER = mixed();
        $GLOBALS['CNS_DRIVER'] = &$this; // Done like this to workaround that PHP can't put a reference in a global'd variable

        if ((addon_installed('ldap')) && (function_exists('ldap_connect')) && (get_option('ldap_is_enabled', true) == '1')) {
            require_code('cns_members');
            require_code('cns_groups');
            require_code('cns_ldap');

            cns_ldap_connect();
        }
    }

    /**
     * Get the rows for the top given number of posters on the forum.
     *
     * @param  integer $limit The limit to the number of top posters to fetch
     * @return array The rows for the given number of top posters in the forum
     */
    public function get_top_posters($limit)
    {
        return $this->connection->query('SELECT * FROM ' . $this->connection->get_table_prefix() . 'f_members WHERE id<>' . strval($this->get_guest_id()) . ' ORDER BY m_cache_num_posts DESC', $limit);
    }

    /**
     * Get the forums' table prefix for the database.
     *
     * @return string The forum database table prefix
     */
    public function get_drivered_table_prefix()
    {
        global $SITE_INFO;
        return empty($SITE_INFO['cns_table_prefix']) ? get_table_prefix() : $SITE_INFO['cns_table_prefix'];
    }

    /**
     * Attempt to to find the member's language from their forum profile. It converts between language-identifiers using a map (lang/map.ini).
     *
     * @param  MEMBER $member The member who's language needs to be fetched
     * @return ?LANGUAGE_NAME The member's language (null: unknown)
     */
    public function forum_get_lang($member)
    {
        return $this->get_member_row_field($member, 'm_language');
    }

    /**
     * Find if login cookie is md5-hashed.
     *
     * @return boolean Whether the login cookie is md5-hashed
     */
    public function is_hashed()
    {
        return true;
    }

    /**
     * Find if the login cookie contains the login name instead of the member ID.
     *
     * @return boolean Whether the login cookie contains a login name or a member ID
     */
    public function is_cookie_login_name()
    {
        return false;
    }

    /**
     * Find the member ID of the forum guest member.
     *
     * @return MEMBER The member ID of the forum guest member
     */
    public function get_guest_id()
    {
        static $ret = null;
        if ($ret === null) {
            $ret = db_get_first_id();
        }
        return $ret;
    }

    /**
     * Add the specified custom field to the forum (some forums implemented this using proper custom profile fields, others through adding a new field).
     *
     * @param  string $name The name of the new custom field
     */
    protected function _install_delete_custom_field($name)
    {
        $id = $this->connection->query_select_value_if_there('f_custom_fields', 'id', array($this->connection->translate_field_ref('cf_name') => 'cms_' . $name));
        if (!is_null($id)) {
            require_code('cns_members_action2');
            cns_delete_custom_field($id);
        }
    }

    /**
     * Add the specified custom field to the forum (some forums implemented this using proper custom profile fields, others through adding a new field).
     *
     * @param  string $name The name of the new custom field
     * @param  integer $length The length of the new custom field (ignored for Conversr, $type used instead)
     * @param  BINARY $locked Whether the field is locked
     * @param  BINARY $viewable Whether the field is for viewing
     * @param  BINARY $settable Whether the field is for setting
     * @param  BINARY $required Whether the field is required
     * @param  string $description Description
     * @param  string $type The field type
     * @param  BINARY $encrypted Whether the field is encrypted
     * @param  ?string $default Default field value (null: standard for field type)
     * @return boolean Whether the custom field was created successfully
     */
    public function install_create_custom_field($name, $length, $locked = 1, $viewable = 0, $settable = 0, $required = 0, $description = '', $type = 'long_text', $encrypted = 0, $default = null)
    {
        require_code('cns_forum_driver_helper_install');
        return _helper_install_create_custom_field($this, $name, $length, $locked, $viewable, $settable, $required, $description, $type, $encrypted, $default);
    }

    /**
     * Get an array of attributes to take in from the installer. Almost all forums require a table prefix, which the requirement there-of is defined through this function.
     * The attributes have 4 values in an array
     * - name, the name of the attribute for _config.php
     * - default, the default value (perhaps obtained through autodetection from forum config)
     * - description, a textual description of the attributes
     * - title, a textual title of the attribute
     *
     * @return array The attributes for the forum
     */
    public function install_specifics()
    {
        require_code('cns_forum_driver_helper_install');
        return _helper_install_specifics();
    }

    /**
     * Searches for forum auto-config at this path.
     *
     * @param  PATH $path The path in which to search
     * @return boolean Whether the forum auto-config could be found
     */
    public function install_test_load_from($path)
    {
        require_code('cns_forum_driver_helper_install');
        return _helper_install_test_load_from($path);
    }

    /**
     * Get an array of paths to search for config at.
     *
     * @return array The paths in which to search for the forum config
     */
    public function install_get_path_search_list()
    {
        return array(get_file_base());
    }

    /**
     * Makes a post in the specified forum, in the specified topic according to the given specifications. If the topic doesn't exist, it is created along with a spacer-post.
     * Spacer posts exist in order to allow staff to delete the first true post in a topic. Without spacers, this would not be possible with most forum systems. They also serve to provide meta information on the topic that cannot be encoded in the title (such as a link to the content being commented upon).
     *
     * @param  SHORT_TEXT $forum_name The forum name
     * @param  SHORT_TEXT $topic_identifier The topic identifier (usually <content-type>_<content-id>)
     * @param  MEMBER $member_id The member ID
     * @param  LONG_TEXT $post_title The post title
     * @param  LONG_TEXT $post The post content in Comcode format
     * @param  string $content_title The topic title; must be same as content title if this is for a comment topic
     * @param  string $topic_identifier_encapsulation_prefix This is put together with the topic identifier to make a more-human-readable topic title or topic description (hopefully the latter and a $content_title title, but only if the forum supports descriptions)
     * @param  ?URLPATH $content_url URL to the content (null: do not make spacer post)
     * @param  ?TIME $time The topic time (null: use current time)
     * @param  ?IP $ip The post IP address (null: use current members IP address)
     * @param  ?BINARY $validated Whether the post is validated (null: unknown, find whether it needs to be marked unvalidated initially). This only works with the Conversr driver.
     * @param  ?BINARY $topic_validated Whether the topic is validated (null: unknown, find whether it needs to be marked unvalidated initially). This only works with the Conversr driver.
     * @param  boolean $skip_post_checks Whether to skip post checks
     * @param  SHORT_TEXT $poster_name_if_guest The name of the poster
     * @param  ?AUTO_LINK $parent_id ID of post being replied to (null: N/A)
     * @param  boolean $staff_only Whether the reply is only visible to staff
     * @param  ?ID_TEXT $no_notify_for__notification_code DO NOT send notifications to: The notification code (null: no restriction)
     * @param  ?SHORT_TEXT $no_notify_for__code_category DO NOT send notifications to: The category within the notification code (null: none / no restriction)
     * @param  ?TIME $time_post The post time (null: use current time)
     * @param  ?MEMBER $spacer_post_member_id Owner of comment topic (null: Guest)
     * @return array Topic ID (may be null), and whether a hidden post has been made
     */
    public function make_post_forum_topic($forum_name, $topic_identifier, $member_id, $post_title, $post, $content_title, $topic_identifier_encapsulation_prefix, $content_url = null, $time = null, $ip = null, $validated = null, $topic_validated = 1, $skip_post_checks = false, $poster_name_if_guest = '', $parent_id = null, $staff_only = false, $no_notify_for__notification_code = null, $no_notify_for__code_category = null, $time_post = null, $spacer_post_member_id = null)
    {
        require_code('cns_forum_driver_helper');
        return _helper_make_post_forum_topic($this, $forum_name, $topic_identifier, $member_id, $post_title, $post, $content_title, $topic_identifier_encapsulation_prefix, $content_url, $time, $ip, $validated, $topic_validated, $skip_post_checks, $poster_name_if_guest, $parent_id, $staff_only, $no_notify_for__notification_code, $no_notify_for__code_category, $time_post, $spacer_post_member_id);
    }

    /**
     * Get an array of topics in the given forum. Each topic is an array with the following attributes:
     * - id, the topic ID
     * - title, the topic title
     * - lastusername, the username of the last poster
     * - lasttime, the timestamp of the last reply
     * - closed, a Boolean for whether the topic is currently closed or not
     * - firsttitle, the title of the first post
     * - firstpost, the first post (only set if $show_first_posts was true)
     *
     * @param  mixed $name The forum name or an array of forum IDs (in such an array the KEYS are forum IDs, and the values ignored)
     * @param  integer $limit The limit
     * @param  integer $start The start position
     * @param  integer $max_rows The total rows (not a parameter: returns by reference)
     * @param  SHORT_TEXT $filter_topic_title The topic title filter
     * @param  boolean $show_first_posts Whether to show the first posts
     * @param  string $date_key The date key to sort by
     * @set    lasttime firsttime
     * @param  boolean $hot Whether to limit to hot topics
     * @param  SHORT_TEXT $filter_topic_description The topic description filter
     * @param  boolean $open_only Open tickets only
     * @return ?array The array of topics (null: error/none)
     */
    public function show_forum_topics($name, $limit, $start, &$max_rows, $filter_topic_title = '', $show_first_posts = false, $date_key = 'lasttime', $hot = false, $filter_topic_description = '', $open_only = false)
    {
        require_code('cns_forum_driver_helper');
        return _helper_show_forum_topics($this, $name, $limit, $start, $max_rows, $filter_topic_title, $filter_topic_description, $show_first_posts, $date_key, $hot, $open_only);
    }

    /**
     * Get an array of maps for the topic in the given forum.
     *
     * @param  integer $topic_id The topic ID
     * @param  integer $count The comment count will be returned here by reference
     * @param  integer $max Maximum comments to returned
     * @param  integer $start Comment to start at
     * @param  boolean $mark_read Whether to mark the topic read
     * @param  boolean $reverse Whether to show in reverse
     * @param  boolean $light_if_threaded Whether to only load minimal details if it is a threaded topic
     * @param  ?array $posts List of post IDs to load (null: no filter)
     * @param  boolean $load_spacer_posts_too Whether to load spacer posts
     * @param  ID_TEXT $sort Preferred sort order (appropriate will use rating if threaded, other
     * @set date rating
     * @return mixed The array of maps (Each map is: title, message, member, date) (-1 for no such forum, -2 for no such topic)
     */
    public function get_forum_topic_posts($topic_id, &$count, $max = 100, $start = 0, $mark_read = true, $reverse = false, $light_if_threaded = false, $posts = null, $load_spacer_posts_too = false, $sort = 'date')
    {
        require_code('cns_forum_driver_helper');
        return _helper_get_forum_topic_posts($this, $topic_id, $count, $max, $start, $mark_read, $reverse, $light_if_threaded, $posts, $load_spacer_posts_too, $sort);
    }

    /**
     * Load extra details for a list of posts. Does not need to return anything if forum driver doesn't support partial post loading (which is only useful for threaded topic partial-display).
     *
     * @param  AUTO_LINK $topic_id Topic the posts come from
     * @param  array $post_ids List of post IDs
     * @return array Extra details
     */
    public function get_post_remaining_details($topic_id, $post_ids)
    {
        require_code('cns_forum_driver_helper');
        return _helper_get_post_remaining_details($this, $topic_id, $post_ids);
    }

    /**
     * Find whether a forum is threaded.
     *
     * @param  integer $topic_id The topic ID
     * @return boolean Whether it is
     */
    public function topic_is_threaded($topic_id)
    {
        if (get_param_integer('threaded', null) === 1) {
            return true;
        }

        global $TOPIC_IS_THREADED_CACHE;
        if (array_key_exists($topic_id, $TOPIC_IS_THREADED_CACHE)) {
            return $TOPIC_IS_THREADED_CACHE[$topic_id] == 1;
        }

        $TOPIC_IS_THREADED_CACHE[$topic_id] = $this->connection->query_select_value_if_there('f_topics t JOIN ' . $this->connection->get_table_prefix() . 'f_forums f ON f.id=t.t_forum_id', 'f_is_threaded', array('t.id' => $topic_id));
        return $TOPIC_IS_THREADED_CACHE[$topic_id] == 1;
    }

    /**
     * Get an emoticon chooser template.
     *
     * @param  string $field_name The ID of the form field the emoticon chooser adds to
     * @return Tempcode The emoticon chooser template
     */
    public function get_emoticon_chooser($field_name = 'post')
    {
        require_code('cns_forum_driver_helper');
        return _helper_get_emoticon_chooser($this, $field_name);
    }

    /**
     * Pin a topic.
     *
     * @param  AUTO_LINK $id The topic ID
     * @param  boolean $pin True: pin it, False: unpin it
     */
    public function pin_topic($id, $pin = true)
    {
        $this->connection->query_update('f_topics', array('t_pinned' => $pin ? 1 : 0), array('id' => $id), '', 1);
    }

    /**
     * Find the base URL to the emoticons.
     *
     * @return URLPATH The base URL
     */
    public function get_emo_dir()
    {
        return '';
    }

    /**
     * Get a map between emoticon codes and templates representing the HTML-image-code for this emoticon. The emoticons presented of course depend on the forum involved.
     *
     * @param  ?MEMBER $member Only emoticons the given member can see (null: don't care)
     * @return array The map
     */
    public function find_emoticons($member = null)
    {
        require_code('cns_forum_driver_helper');
        return _helper_apply_emoticons($this, $member);
    }

    /**
     * Try to find the theme that the logged-in/guest member is using, and map it to a Composr theme.
     * The themes/map.ini file functions to provide this mapping between forum themes, and Composr themes, and has a slightly different meaning for different forum drivers. For example, some drivers map the forum themes theme directory to the Composr theme name, while others made the humanly readeable name.
     *
     * @param  boolean $skip_member_specific Whether to avoid member-specific lookup (i.e. find via what forum theme is currently configured as the default)
     * @param  ?MEMBER $member The member to find for (null: current member)
     * @return ID_TEXT The theme
     */
    public function _get_theme($skip_member_specific = false, $member = null)
    {
        if ($member === null) {
            $member = get_member();
        }
        $theme = '';
        if (!$skip_member_specific) {
            if ($member != $this->get_guest_id()) {
                $theme = $this->get_member_row_field($member, 'm_theme');
            }
        }
        if (($theme == '') || ($theme == '-1')) {
            if (!GOOGLE_APPENGINE) { // Requires a Cloud Storage fstat on GAE, so bad idea - user should be explicit in their zone setup (which the Theme Wizard will do)
                $theme = get_default_theme_name();
                if (is_dir(get_custom_file_base() . '/themes/' . $theme)) {
                    return $theme;
                }
                $theme = 'default';
            }
        }
        return $theme;
    }

    /**
     * Set a custom profile field's value, if the custom field exists. Only works on specially-named (titled) fields.
     *
     * @param  MEMBER $member The member ID
     * @param  string $field The field name (e.g. "firstname" for the CPF with a title of "cms_firstname") (e.g. "firstname" for the CPF with a title of "cms_firstname")
     * @param  string $value The value
     */
    public function set_custom_field($member, $field, $value)
    {
        // Check member exists
        $username = $this->get_username($member);
        if (is_null($username)) {
            return;
        }

        require_code('cns_members_action');
        require_code('cns_members_action2');

        $field_bits = $this->connection->query_select('f_custom_fields', array('id', 'cf_type'), array($this->connection->translate_field_ref('cf_name') => 'cms_' . $field));
        if (!array_key_exists(0, $field_bits)) { // Should never happen, but sometimes on upgrades/corruption...
            $this->install_create_custom_field($field, 10);
            $field_bits = $this->connection->query_select('f_custom_fields', array('id', 'cf_type'), array($this->connection->translate_field_ref('cf_name') => 'cms_' . $field));
            if (!array_key_exists(0, $field_bits)) {
                return; // Possible on an MSN, and there's an inconsistency (e.g. no points addon)
            }
        }
        $field_id = $field_bits[0]['id'];
        cns_set_custom_field($member, $field_id, $value);
    }

    /**
     * Get custom profile fields values for all 'cms_' prefixed keys.
     *
     * @param  MEMBER $member The member ID
     * @return ?array A map of the custom profile fields, key_suffix=>value (null: no fields)
     */
    public function get_custom_fields($member)
    {
        // Check member exists
        $username = $this->get_username($member);
        if (is_null($username)) {
            return null;
        }

        require_code('cns_members');

        $info = cns_get_all_custom_fields_match_member(
            $member, // member
            null, // public view
            null, // owner view
            null, // owner set
            null, // encrypted
            null, // required
            null, // show in posts
            null, // show in post previews
            1 // special
        );
        $out = array();
        foreach ($info as $field => $value) {
            $out[substr($field, 4)] = $value['RAW'];
        }
        return $out;
    }

    /**
     * Get a member row for the member of the given name.
     *
     * @param  SHORT_TEXT $name The member name
     * @return ?array The profile-row (null: could not find)
     */
    public function get_mrow($name)
    {
        foreach ($this->MEMBER_ROWS_CACHED as $i => $row) {
            if ($row === null) {
                continue;
            }

            if ($row['m_username'] == $name) {
                return $row;
            }
        }
        $rows = $this->connection->query_select('f_members', array('*'), array('m_username' => $name), '', 1);
        if (!array_key_exists(0, $rows)) {
            return null;
        }
        return $rows[0];
    }

    /**
     * From a member row, get the member's primary usergroup.
     *
     * @param  array $r The profile-row
     * @return GROUP The member's primary usergroup
     */
    public function mrow_group($r)
    {
        require_code('cns_members');
        return cns_get_member_primary_group($r['id']);
    }

    /**
     * From a member row, get the member's member ID.
     *
     * @param  array $r The profile-row
     * @return MEMBER The member ID
     */
    public function mrow_id($r)
    {
        return $r['id'];
    }

    /**
     * From a member row, get the member's last visit date.
     *
     * @param  array $r The profile-row
     * @return TIME The last visit date
     */
    public function mrow_lastvisit($r)
    {
        return $r['m_last_visit_time'];
    }

    /**
     * From a member row, get the member's name.
     *
     * @param  array $r The profile-row
     * @return string The member name
     */
    public function mrow_username($r)
    {
        return $r['m_username'];
    }

    /**
     * From a member row, get the member's e-mail address.
     *
     * @param  array $r The profile-row
     * @return SHORT_TEXT The member e-mail address
     */
    public function mrow_email($r)
    {
        return $r['m_email_address'];
    }

    /**
     * Get a URL to the specified member's home (control panel).
     *
     * @param  MEMBER $id The member ID
     * @param  boolean $tempcode_okay Whether it is okay to return the result using Tempcode (more efficient, and allows keep_* parameters to propagate which you almost certainly want!)
     * @return mixed The URL to the members home
     */
    public function member_home_url($id, $tempcode_okay = false)
    {
        $_url = build_url(array('page' => 'members', 'type' => 'view', 'id' => ($id == get_member()) ? null : $id), get_module_zone('members'), null, false, false, false, 'tab__edit');
        if (($tempcode_okay) && (get_base_url() == get_forum_base_url())) {
            return $_url;
        }
        $url = $_url->evaluate();
        if (get_option('forum_in_portal') == '0') {
            $url = str_replace(get_base_url(), get_forum_base_url(), $url);
        }
        return $url;
    }

    /**
     * Get a URL to the specified member's profile.
     *
     * @param  MEMBER $id The member ID
     * @param  boolean $tempcode_okay Whether it is okay to return the result using Tempcode (more efficient, and allows keep_* parameters to propagate which you almost certainly want!)
     * @param  ?string $username Username, passed for performance reasons (null: look it up)
     * @return mixed The URL to the member profile
     */
    protected function _member_profile_url($id, $tempcode_okay = false, $username = null)
    {
        if (get_option('username_profile_links') == '1') {
            if ($username === null) {
                $username = $GLOBALS['FORUM_DRIVER']->get_username($id);
            }
            $map = array('page' => 'members', 'type' => 'view', 'id' => ($id == get_member() && $tempcode_okay) ? null : (($username === null) ? strval($id) : $username));
            if (get_page_name() == 'members') {
                $map += propagate_filtercode();
            }
            $_url = build_url($map, get_module_zone('members'), null, false, false, !$tempcode_okay);
        } else {
            $map = array('page' => 'members', 'type' => 'view', 'id' => ($id == get_member() && $tempcode_okay) ? null : $id);
            if (get_page_name() == 'members') {
                $map += propagate_filtercode();
            }
            $_url = build_url($map, get_module_zone('members'), null, false, false, !$tempcode_okay);
        }
        if (($tempcode_okay) && (get_base_url() == get_forum_base_url())) {
            return $_url;
        }
        $url = $_url->evaluate();
        if (get_option('forum_in_portal') == '0') {
            $url = str_replace(get_base_url(), get_forum_base_url(), $url);
        }
        return $url;
    }

    /**
     * Get a URL to the registration page (for people to create member accounts).
     *
     * @return URLPATH The URL to the registration page
     */
    protected function _join_url()
    {
        $page = '_SELF';
        if (has_interesting_post_fields()) {
            $page = '';
        }
        if (get_page_name() == 'join') {
            $_redirect_url = build_url(array('page' => 'start'), '');
        } else {
            $_redirect_url = build_url(array('page' => $page), '_SELF', array('keep_session' => 1, 'redirect' => 1), true);
        }
        $redirect_url = $_redirect_url->evaluate();

        $redirect_url = get_param_string('redirect_passon', get_param_string('redirect', $redirect_url));

        $_url = build_url(array('page' => 'join', 'redirect' => (get_page_name() == 'recommend') ? null : $redirect_url), get_module_zone('join'), array('keep_session' => 1, 'redirect' => 1));
        $url = $_url->evaluate();
        if (get_option('forum_in_portal') == '0') {
            $url = str_replace(get_base_url(), get_forum_base_url(), $url);
        }
        return $url;
    }

    /**
     * Get a URL to the members-online page.
     *
     * @param  boolean $tempcode_okay Whether it is okay to return the result using Tempcode (more efficient)
     * @return mixed The URL to the members-online page
     */
    protected function _users_online_url($tempcode_okay = false)
    {
        $_url = build_url(array('page' => 'users_online'), get_module_zone('users_online'));
        if (($tempcode_okay) && (get_base_url() == get_forum_base_url())) {
            return $_url;
        }
        $url = $_url->evaluate();
        if (get_option('forum_in_portal') == '0') {
            $url = str_replace(get_base_url(), get_forum_base_url(), $url);
        }
        return $url;
    }

    /**
     * Get a URL to send a private/personal message to the given member.
     *
     * @param  MEMBER $id The member ID
     * @param  boolean $tempcode_okay Whether it is okay to return the result using Tempcode (more efficient)
     * @return mixed The URL to the private/personal message page
     */
    protected function _member_pm_url($id, $tempcode_okay = false)
    {
        $_url = build_url(array('page' => 'topics', 'type' => 'new_pt', 'id' => $id), get_module_zone('topics'), null, false, false, true);
        if (($tempcode_okay) && (get_base_url() == get_forum_base_url())) {
            return $_url;
        }
        $url = $_url->evaluate();
        if (get_option('forum_in_portal') == '0') {
            $url = str_replace(get_base_url(), get_forum_base_url(), $url);
        }
        return $url;
    }

    /**
     * Get a URL to the specified forum.
     *
     * @param  integer $id The forum ID
     * @param  boolean $tempcode_okay Whether it is okay to return the result using Tempcode (more efficient)
     * @return mixed The URL to the specified forum
     */
    protected function _forum_url($id, $tempcode_okay = false)
    {
        $view_map = array('page' => 'forumview');
        if ($id != db_get_first_id()) {
            $view_map['id'] = $id;
        }
        $_url = build_url($view_map, get_module_zone('forumview'), null, false, false, !$tempcode_okay);
        if (($tempcode_okay) && (get_base_url() == get_forum_base_url())) {
            return $_url;
        }
        $url = $_url->evaluate();
        if (get_option('forum_in_portal') == '0') {
            $url = str_replace(get_base_url(), get_forum_base_url(), $url);
        }
        return $url;
    }

    /**
     * Get the forum ID from a forum name.
     *
     * @param  SHORT_TEXT $forum_name The forum name
     * @return ?integer The forum ID (null: not found)
     */
    public function forum_id_from_name($forum_name)
    {
        global $FORUM_NAMES_TO_IDS_CACHE;
        if (array_key_exists($forum_name, $FORUM_NAMES_TO_IDS_CACHE)) {
            return $FORUM_NAMES_TO_IDS_CACHE[$forum_name];
        }

        if (is_numeric($forum_name)) {
            $result = intval($forum_name);
        } else {
            $_result = $this->connection->query_select('f_forums', array('id', 'f_is_threaded'), array('f_name' => $forum_name), '', 1);
            $result = mixed();
            if (array_key_exists(0, $_result)) {
                $result = $_result[0]['id'];
            }
        }

        $FORUM_NAMES_TO_IDS_CACHE[$forum_name] = $result;
        return $result;
    }

    /**
     * Get the topic ID from a topic identifier in the specified forum. It is used by comment topics, which means that the unique-topic-name assumption holds valid.
     *
     * @param  string $forum The forum name / ID
     * @param  SHORT_TEXT $topic_identifier The topic identifier
     * @param  ?string $topic_identifier_encapsulation_prefix This is put together with the topic identifier to make a more-human-readable topic title or topic description (hopefully the latter and a $content_title title, but only if the forum supports descriptions). Set this to improve performance (null: unknown)
     * @return ?integer The topic ID (null: not found)
     */
    public function find_topic_id_for_topic_identifier($forum, $topic_identifier, $topic_identifier_encapsulation_prefix = null)
    {
        $key = serialize(array($forum, $topic_identifier));

        global $TOPIC_IDENTIFIERS_TO_IDS_CACHE;
        if (array_key_exists($key, $TOPIC_IDENTIFIERS_TO_IDS_CACHE)) {
            return $TOPIC_IDENTIFIERS_TO_IDS_CACHE[$key];
        }
        if (is_numeric($forum)) {
            $result = intval($forum);
        } else {
            $result = $this->forum_id_from_name($forum);
            if ($forum == get_option('comments_forum_name')) { // Fix performance for next time
                require_code('config2');
                set_option('comments_forum_name', strval($result));
            }
        }

        if (is_integer($forum)) {
            $forum_id = $forum;
        } else {
            $forum_id = $this->forum_id_from_name($forum);
        }
        if (is_null($forum_id)) {
            return null;
        }

        $query = 'SELECT t.id,f_is_threaded FROM ' . $this->connection->get_table_prefix() . 'f_topics t JOIN ' . $this->connection->get_table_prefix() . 'f_forums f ON f.id=t.t_forum_id WHERE t_forum_id=' . strval($forum_id) . ' AND ';
        $query .= '(';
        $query .= db_string_equal_to('t_description', $topic_identifier);
        if ($topic_identifier_encapsulation_prefix === null) {
            $query .= ' OR t_description LIKE \'%: #' . db_encode_like($topic_identifier) . '\'';
        } else {
            $query .= ' OR ' . db_string_equal_to('t_description', $topic_identifier_encapsulation_prefix . ': #' . $topic_identifier);
        }
        $query .= ' OR t_cache_first_title LIKE \'% (#' . db_encode_like($topic_identifier) . ')\''; // LEGACY
        $query .= ')';

        $_result = $this->connection->query($query, 1, null, false, true);
        if (array_key_exists(0, $_result)) {
            $TOPIC_IDENTIFIERS_TO_IDS_CACHE[$key] = $_result[0]['id'];
            global $TOPIC_IS_THREADED_CACHE;
            $TOPIC_IS_THREADED_CACHE[$_result[0]['id']] = $_result[0]['f_is_threaded'];
        } else {
            $TOPIC_IDENTIFIERS_TO_IDS_CACHE[$key] = null;
        }
        return $TOPIC_IDENTIFIERS_TO_IDS_CACHE[$key];
    }

    /**
     * Get a URL to the specified topic ID. Most forums don't require the second parameter, but some do, so it is required in the interface.
     *
     * @param  integer $id The topic ID
     * @param  string $forum The forum ID
     * @param  boolean $tempcode_okay Whether it is okay to return the result using Tempcode (more efficient)
     * @return mixed The URL to the topic
     */
    public function topic_url($id, $forum = '', $tempcode_okay = false)
    {
        if (is_null($id)) {
            return ''; // Should not happen, but if it does, this is how we should handle it.
        }

        unset($forum);
        $_url = build_url(array('page' => 'topicview', 'id' => $id), get_module_zone('topicview'), null, false, false, !$tempcode_okay);
        if (($tempcode_okay) && (get_base_url() == get_forum_base_url())) {
            return $_url;
        }
        $url = $_url->evaluate();
        if (get_option('forum_in_portal') == '0') {
            $url = str_replace(get_base_url(), get_forum_base_url(), $url);
        }
        return $url;
    }

    /**
     * Get a URL to the specified post ID.
     *
     * @param  integer $id The post ID
     * @param  string $forum The forum ID
     * @param  boolean $tempcode_okay Whether it is okay to return the result using Tempcode (more efficient)
     * @return mixed The URL to the post
     */
    public function post_url($id, $forum, $tempcode_okay = false)
    {
        if (is_null($id)) {
            return ''; // Should not happen, but if it does, this is how we should handle it.
        }

        unset($forum);

        $_url = build_url(array('page' => 'topicview', 'type' => 'findpost', 'id' => $id), get_module_zone('topicview'), null, false, false, !$tempcode_okay);
        if (($tempcode_okay) && (get_base_url() == get_forum_base_url())) {
            return $_url;
        }
        $url = $_url->evaluate();
        $url .= '#post_' . strval($id);
        if (get_option('forum_in_portal') == '0') {
            $url = str_replace(get_base_url(), get_forum_base_url(), $url);
        }
        return $url;
    }

    /**
     * Get an array of members who are in at least one of the given array of usergroups.
     *
     * @param  array $groups The array of usergroups
     * @param  ?integer $max Return up to this many entries for primary members and this many entries for secondary members (null: no limit, only use no limit if querying very restricted usergroups!)
     * @param  integer $start Return primary members after this offset and secondary members after this offset
     * @return ?array The map of members, member ID to details (null: no members)
     */
    public function member_group_query($groups, $max = null, $start = 0)
    {
        $out = array();

        if (db_has_subqueries($this->connection->connection_read)) {
            $_groups = '';
            foreach ($groups as $group) {
                if ($_groups != '') {
                    $_groups .= ',';
                }
                $_groups .= strval($group);
            }
            if ($_groups == '') {
                return array();
            }
            $sql = 'SELECT * FROM ' . $this->connection->get_table_prefix() . 'f_members m WHERE m_primary_group IN (' . $_groups . ') OR EXISTS(SELECT * FROM ' . $this->connection->get_table_prefix() . 'f_group_members WHERE gm_group_id IN (' . $_groups . ') AND gm_member_id=m.id AND gm_validated=1) ORDER BY m_primary_group ASC,id ASC';
            $a = $this->connection->query($sql, $max, $start, false, true);
            foreach ($a as $x) {
                $out[$x['id']] = $x;
            }
        } else { // This can be removed in the future, when we reduce our ancient MySQL tolerance
            $_groups = '';
            foreach ($groups as $group) {
                if ($_groups != '') {
                    $_groups .= ' OR ';
                }
                $_groups .= 'gm_group_id=' . strval($group);
            }
            if ($_groups == '') {
                return array();
            }
            $sql = 'SELECT u.* FROM ' . $this->connection->get_table_prefix() . 'f_group_members g JOIN ' . $this->connection->get_table_prefix() . 'f_members u ON u.id=g.gm_member_id WHERE (' . $_groups . ') AND gm_validated=1 ORDER BY g.gm_group_id ASC';
            $a = $this->connection->query($sql, $max, $start, false, true);
            foreach ($a as $x) {
                if (!array_key_exists($x['id'], $out)) {
                    $out[$x['id']] = $x;
                }
            }

            $_groups = '';
            foreach ($groups as $group) {
                if ($_groups != '') {
                    $_groups .= ' OR ';
                }
                $_groups .= 'm_primary_group=' . strval($group);
            }
            $sql = 'SELECT * FROM ' . $this->connection->get_table_prefix() . 'f_members WHERE ' . $_groups . ' ORDER BY m_primary_group ASC';
            $b = $this->connection->query($sql, $max, $start, false, true);
            foreach ($b as $x) {
                if (!array_key_exists($x['id'], $out)) {
                    $out[$x['id']] = $x;
                }
            }
        }

        // Now implicit usergroup hooks
        if ($start == 0) {
            $hooks = find_all_hooks('systems', 'cns_implicit_usergroups');
            foreach ($hooks as $hook => $hook_dir) {
                require_code('hooks/systems/cns_implicit_usergroups/' . $hook, false, $hook_dir == 'sources_custom');
                $ob = object_factory('Hook_implicit_usergroups_' . $hook);
                $group_ids = $ob->get_bound_group_ids();
                foreach ($group_ids as $group_id) {
                    if (in_array($group_id, $groups)) {
                        $c = $ob->get_member_list($group_id, $max, $start);
                        if (!is_null($c)) {
                            foreach ($c as $member_id => $x) {
                                $out[$member_id] = $x;
                            }
                        }
                    }
                }
            }
        }

        return $out;
    }

    /**
     * This is the opposite of the get_next_member function.
     *
     * @param  MEMBER $member The member ID to decrement
     * @return ?MEMBER The previous member ID (null: no previous member)
     */
    public function get_previous_member($member)
    {
        $sql = 'SELECT id FROM ' . $this->connection->get_table_prefix() . 'f_members WHERE id<' . strval($member) . ' AND id>0 AND ' . db_string_equal_to('m_validated_email_confirm_code', '');
        if (addon_installed('unvalidated')) {
            $sql .= ' AND m_validated=1';
        }
        $sql .= ' ORDER BY id DESC';
        $tempid = $this->connection->query_value_if_there($sql);
        if ($tempid == $this->get_guest_id()) {
            return null;
        }
        return $tempid;
    }

    /**
     * Get the member ID of the next member after the given one, or null.
     * It cannot be assumed there are no gaps in member IDs, as members may be deleted.
     *
     * @param  MEMBER $member The member ID to increment
     * @return ?MEMBER The next member ID (null: no next member)
     */
    public function get_next_member($member)
    {
        $sql = 'SELECT id FROM ' . $this->connection->get_table_prefix() . 'f_members WHERE id>' . strval($member) . ' AND ' . db_string_equal_to('m_validated_email_confirm_code', '');
        if (addon_installed('unvalidated')) {
            $sql .= ' AND m_validated=1';
        }
        $sql .= ' ORDER BY id';
        $tempid = $this->connection->query_value_if_there($sql);
        return $tempid;
    }

    /**
     * Try to find a member with the given IP address
     *
     * @param  IP $ip The IP address
     * @return array The distinct rows found
     */
    public function probe_ip($ip)
    {
        if (strpos($ip, '*') !== false) {
            $a = $this->connection->query('SELECT DISTINCT id FROM ' . $this->connection->get_table_prefix() . 'f_members WHERE m_ip_address LIKE \'' . db_encode_like(str_replace('*', '%', $ip)) . '\'');
            $b = $this->connection->query('SELECT DISTINCT p_poster AS id FROM ' . $this->connection->get_table_prefix() . 'f_posts WHERE p_ip_address LIKE \'' . db_encode_like(str_replace('*', '%', $ip)) . '\'');
        } else {
            $a = $this->connection->query_select('f_members', array('DISTINCT id'), array('m_ip_address' => $ip));
            $b = $this->connection->query_select('f_posts', array('DISTINCT p_poster AS id'), array('p_ip_address' => $ip));
        }
        return array_merge($a, $b);
    }

    /**
     * Get the name relating to the specified member ID.
     * If this returns null, then the member has been deleted. Always take potential null output into account.
     *
     * @param  MEMBER $member The member ID
     * @return ?SHORT_TEXT The member name (null: member deleted)
     */
    protected function _get_username($member)
    {
        if ($member == $this->get_guest_id()) {
            return do_lang('GUEST');
        }
        return $this->get_member_row_field($member, 'm_username');
    }

    /**
     * Get the display name of a username.
     * If no display name generator is configured, this will be the same as the username.
     *
     * @param  ID_TEXT $username The username
     * @return SHORT_TEXT The display name
     */
    protected function _get_displayname($username)
    {
        $generator = get_option('display_name_generator');
        if ($generator != '') {
            $member_id = $GLOBALS['FORUM_DRIVER']->get_member_from_username($username);
            if (!is_null($member_id)) {
                require_code('cns_members');
                $custom_fields = cns_get_all_custom_fields_match_member($member_id);

                $username_bak = $username;

                $username = $generator;

                $matches = array();
                $num_matches = preg_match_all('#\{(\d+)\}#', $generator, $matches);
                for ($i = 0; $i < $num_matches; $i++) {
                    $field_id = $matches[1][$i];
                    foreach ($custom_fields as $custom_field) {
                        if ($custom_field['FIELD_ID'] == $field_id) {
                            $username = str_replace($matches[0][$i], $custom_field['RAW'], $username);
                        }
                    }
                }

                $username = preg_replace('# +#', ' ', trim($username)); // Strip any double (or triple, etc) blanks, and leading/trailing blanks

                if ($username == '') {
                    $username = $username_bak;
                }
            }
        }

        return $username;
    }

    /**
     * Get the e-mail address for the specified member ID.
     *
     * @param  MEMBER $member The member ID
     * @return SHORT_TEXT The e-mail address
     */
    protected function _get_member_email_address($member)
    {
        return $this->get_member_row_field($member, 'm_email_address');
    }

    /**
     * Get the photo thumbnail URL for the specified member ID.
     *
     * @param  MEMBER $member The member ID
     * @return URLPATH The URL (blank: none)
     */
    public function get_member_photo_url($member)
    {
        if ($member == db_get_first_id()) {
            return '';
        }

        $privacy_ok = true;
        if (addon_installed('content_privacy')) {
            require_code('content_privacy');
            $privacy_ok = has_privacy_access('_photo', strval($member), get_member(), '', $member);
        }

        if ((!addon_installed('cns_member_photos')) || (!has_privilege(get_member(), 'view_member_photos')) || (!$privacy_ok)) {
            if (!addon_installed('cns_member_avatars')) {
                return '';
            }
            return $this->get_member_avatar_url($member);
        }

        $pic = $this->get_member_row_field($member, 'm_photo_thumb_url');

        if ($pic == '') {
            $photo_url = $GLOBALS['FORUM_DRIVER']->get_member_row_field($member, 'm_photo_url');
            if ($photo_url != '') {
                require_code('images');
                $pic = ensure_thumbnail($photo_url, $pic, (strpos($photo_url, 'uploads/photos') !== false) ? 'photos' : 'cns_photos', 'f_members', $member, 'm_photo_thumb_url');
            }
        }

        if (is_null($pic)) {
            $pic = '';
        } elseif ((url_is_local($pic)) && ($pic != '')) {
            $pic = get_complex_base_url($pic) . '/' . $pic;
        }

        return $pic;
    }

    /**
     * Get the avatar URL for the specified member ID.
     *
     * @param  MEMBER $member The member ID
     * @return URLPATH The URL (blank: none)
     */
    public function get_member_avatar_url($member)
    {
        if ((!addon_installed('cns_member_avatars')) && (!addon_installed('cns_member_photos'))) {
            return '';
        }

        if ($member == db_get_first_id()) {
            return '';
        }

        /*if (!addon_installed('cns_member_avatars')) Actually when photo is chosen, avatar is set - and will have been resized right
        {
            if (!addon_installed('cns_member_photos')) return '';
            return $this->get_member_photo_url($member);
        }*/

        $avatar = $this->get_member_row_field($member, 'm_avatar_url');
        if (is_null($avatar)) {
            $avatar = '';
        } else {
            $base_url = get_base_url();
            if ((!is_on_multi_site_network()) && (substr($avatar, 0, strlen($base_url) + 1) == $base_url . '/')) { // So we can do an is_file check
                $avatar = substr($avatar, strlen($base_url) + 1);
            }

            if ((url_is_local($avatar)) && ($avatar != '')) {
                if ((is_on_multi_site_network()) || (is_file(get_file_base() . '/' . rawurldecode($avatar))) || (is_file(get_custom_file_base() . '/' . rawurldecode($avatar)))) {
                    $avatar = get_complex_base_url($avatar) . '/' . $avatar;
                } else {
                    $avatar = '';
                }
            }
        }

        return cdn_filter($avatar);
    }

    /**
     * Find if this member may have e-mails sent to them
     *
     * @param  MEMBER $member The member ID
     * @return boolean Whether the member may have e-mails sent to them
     */
    public function get_member_email_allowed($member)
    {
        if (get_option('allow_email_disable') == '0') {
            return true;
        }
        return $this->get_member_row_field($member, 'm_allow_emails');
    }

    /**
     * Get the timestamp of a member's join date.
     *
     * @param  MEMBER $member The member ID
     * @return TIME The timestamp
     */
    public function get_member_join_timestamp($member)
    {
        return $this->get_member_row_field($member, 'm_join_time');
    }

    /**
     * Find all members with a name matching the given SQL LIKE string.
     *
     * @param  string $pattern The pattern
     * @param  ?integer $limit Maximum number to return (limits to the most recent active) (null: no limit)
     * @param  boolean $friends Whether to limit to friends of the current member, if possible
     * @return ?array The array of matched members (null: none found)
     */
    public function get_matching_members($pattern, $limit = null, $friends = false)
    {
        if (!addon_installed('chat')) {
            $friends = false;
        }
        if (is_cns_satellite_site()) {
            $friends = false;
        }
        if (is_guest()) {
            $friends = false;
        }

        $like = 'm_username LIKE \'' . db_encode_like($pattern) . '\' AND ';
        if (($pattern == '') || ($pattern == '%')) {
            $like = '';
        }
        $sql = 'SELECT * FROM ' . $this->connection->get_table_prefix() . 'f_members';
        if ($friends) {
            $sql .= ' JOIN ' . $this->connection->get_table_prefix() . 'chat_friends ON member_liked=id AND member_likes=' . strval(get_member());
        }
        $sql .= ' WHERE ' . $like . 'id<>' . strval($this->get_guest_id());
        $sql .= ' ORDER BY m_last_submit_time DESC';
        $rows = $this->connection->query($sql, $limit);

        sort_maps_by($rows, 'm_username');

        return $rows;
    }

    /**
     * Get the given member's post count.
     *
     * @param  MEMBER $member The member ID
     * @return integer The post count
     */
    public function get_post_count($member)
    {
        return $this->get_member_row_field($member, 'm_cache_num_posts');
    }

    /**
     * Get the given member's topic count.
     *
     * @param  MEMBER $member The member ID
     * @return integer The topic count
     */
    public function get_topic_count($member)
    {
        return $this->connection->query_select_value('f_topics', 'COUNT(*)', array('t_cache_first_member_id' => $member));
    }

    /**
     * Find out if the given member ID is banned.
     *
     * @param  MEMBER $member The member ID
     * @return boolean Whether the member is banned
     */
    public function is_banned($member)
    {
        return $this->get_member_row_field($member, 'm_is_perm_banned') == 1;
    }

    /**
     * Find if the specified member ID is marked as staff or not.
     *
     * @param  MEMBER $member The member ID
     * @return boolean Whether the member is staff
     */
    protected function _is_staff($member)
    {
        if ($member == $this->get_guest_id()) {
            return false;
        }
        $users_groups = $this->get_members_groups($member);
        return ((cns_get_best_group_property($users_groups, 'is_super_moderator') == 1) || (cns_get_best_group_property($users_groups, 'is_super_admin') == 1));
    }

    /**
     * Find if the specified member ID is marked as a super admin or not.
     *
     * @param  MEMBER $member The member ID
     * @return boolean Whether the member is a super admin
     */
    protected function _is_super_admin($member)
    {
        if ($member == $this->get_guest_id()) {
            return false;
        }
        $users_groups = $this->get_members_groups($member);
        return cns_get_best_group_property($users_groups, 'is_super_admin') == 1;
    }

    /**
     * Get the number of members currently online on the forums.
     *
     * @return ?integer The number of members (null: the same as the site number)
     */
    public function get_num_users_forums()
    {
        return null; // Same as site
    }

    /**
     * Get the number of members registered on the forum.
     *
     * @return integer The number of members
     */
    public function get_members()
    {
        $value = intval(get_value_newer_than('cns_member_count', time() - 60 * 60 * 3));

        if ($value == 0) {
            $where = array('m_validated_email_confirm_code' => '');
            if (addon_installed('unvalidated')) {
                $where['m_validated'] = 1;
            }
            $value = max(0, get_table_count_approx('f_members', $where, null, $this->connection) - 1);
            if (!$GLOBALS['SITE_DB']->table_is_locked('values')) {
                set_value('cns_member_count', strval($value));
            }
        }

        return $value;
    }

    /**
     * Get the total topics ever made on the forum.
     *
     * @return integer The number of topics
     */
    public function get_topics()
    {
        $value = intval(get_value_newer_than('cns_topic_count', time() - 60 * 60 * 3));

        if ($value == 0) {
            $where = array();
            if (addon_installed('unvalidated')) {
                $where['t_validated'] = 1;
            }
            $value = get_table_count_approx('f_topics', $where, null, $this->connection);
            if (!$GLOBALS['SITE_DB']->table_is_locked('values')) {
                set_value('cns_topic_count', strval($value));
            }
        }

        return $value;
    }

    /**
     * Get the total posts ever made on the forum.
     *
     * @return integer The number of posts
     */
    public function get_num_forum_posts()
    {
        $value = intval(get_value_newer_than('cns_post_count', time() - 60 * 60 * 3));

        if ($value == 0) {
            $where = '';
            if (addon_installed('unvalidated')) {
                $where = ' AND p_validated=1';
            }
            $where = 'p_cache_forum_id IS NOT NULL' . $where;
            $value = get_table_count_approx('f_posts', null, $where, $this->connection);
            if (!$GLOBALS['SITE_DB']->table_is_locked('values')) {
                set_value('cns_post_count', strval($value));
            }
        }

        return $value;
    }

    /**
     * Get the number of new forum posts.
     *
     * @return integer The number of posts
     */
    protected function _get_num_new_forum_posts()
    {
        return $this->connection->query_value_if_there('SELECT COUNT(*) FROM ' . $this->connection->get_table_prefix() . 'f_posts WHERE p_time>' . strval(time() - 60 * 60 * 24));
    }

    /**
     * Get a member ID from the given member's username. If there is no match and the input is numeric, it will also try it as a member ID.
     *
     * @param  SHORT_TEXT $name The member name
     * @return ?MEMBER The member ID (null: not found)
     */
    public function get_member_from_username($name)
    {
        foreach ($this->MEMBER_ROWS_CACHED as $id => $row) {
            if ($row === null) {
                continue;
            }

            if ($row['m_username'] == $name) {
                return $id;
            }
        }
        $row = $this->connection->query_select('f_members', array('*'), array('m_username' => $name), '', 1);
        if (!array_key_exists(0, $row)) {
            if ((is_numeric($name)) && (!is_null($this->get_username(intval($name))))) {
                return intval($name);
            }
            return null;
        }
        $id = $row[0]['id'];
        $this->MEMBER_ROWS_CACHED[$id] = $row[0];
        return $id;
    }

    /**
     * Get a member ID from the given member's username.
     *
     * @param  SHORT_TEXT $email_address The member email address
     * @return ?MEMBER The member ID (null: not found)
     */
    public function get_member_from_email_address($email_address)
    {
        foreach ($this->MEMBER_ROWS_CACHED as $id => $row) {
            if ($row === null) {
                continue;
            }

            if ($row['m_email_address'] == $email_address) {
                return $id;
            }
        }
        $row = $this->connection->query_select('f_members', array('*'), array('m_email_address' => $email_address), 'ORDER BY m_is_perm_banned,m_join_time DESC', 1);
        if (!array_key_exists(0, $row)) {
            return null;
        }
        $id = $row[0]['id'];
        $this->MEMBER_ROWS_CACHED[$id] = $row[0];
        return $id;
    }

    /**
     * Get the IDs of the admin usergroups.
     *
     * @return array The admin usergroup IDs
     */
    protected function _get_super_admin_groups()
    {
        $ret = function_exists('persistent_cache_get') ? persistent_cache_get('SUPER_ADMIN_GROUPS') : null;

        if ($ret === null) {
            $_ret = $this->connection->query_select('f_groups', array('id'), array('g_is_super_admin' => 1), '', null, null, running_script('install')/*may not be installed yet*/);
            if ($_ret === null) {
                return array();
            }
            $ret = collapse_1d_complexity('id', $_ret);

            if (function_exists('persistent_cache_set')) {
                persistent_cache_set('SUPER_ADMIN_GROUPS', $ret);
            }
        }

        return $ret;
    }

    /**
     * Get the IDs of the moderator usergroups.
     * It should not be assumed that a member only has one usergroup - this depends upon the forum the driver works for. It also does not take the staff site filter into account.
     *
     * @return array The moderator usergroup IDs
     */
    protected function _get_moderator_groups()
    {
        $ret = function_exists('persistent_cache_get') ? persistent_cache_get('SUPER_MODERATOR_GROUPS') : null;

        if ($ret === null) {
        $_ret = $this->connection->query_select('f_groups', array('id'), array('g_is_super_moderator' => 1), '', null, null, running_script('install')/*may not be installed yet*/);
            if ($_ret === null) {
                return array();
            }
            $ret = collapse_1d_complexity('id', $_ret);

            if (function_exists('persistent_cache_set')) {
                persistent_cache_set('SUPER_MODERATOR_GROUPS', $ret);
            }
        }

        return $ret;
    }

    /**
     * Get the forum usergroup list. This is useful to enumerate usergroups, or to find usergroup names.
     *
     * @param  boolean $hide_hidden Whether to obscure the name of hidden usergroups
     * @param  boolean $only_permissive Whether to only grab permissive usergroups
     * @param  boolean $force_show_all Do not limit things even if there are huge numbers of usergroups
     * @param  ?array $force_find Usergroups that must be included in the results (null: no extras must be)
     * @param  ?MEMBER $for_member Always return usergroups of this member if otherwise there are too many to return in full (null: current member)
     * @param  boolean $skip_hidden Whether to completely skip hidden usergroups
     * @return array The usergroup list, a map of usergroup ID to usergroup name
     */
    protected function _get_usergroup_list($hide_hidden = false, $only_permissive = false, $force_show_all = false, $force_find = null, $for_member = null, $skip_hidden = false)
    {
        if (($hide_hidden) && (has_privilege(get_member(), 'see_hidden_groups'))) {
            $hide_hidden = false;
        }

        $where = $only_permissive ? ' WHERE g_is_private_club=0' : '';

        $select = 'g.id,g_name,g.g_hidden';
        $sup = ' ORDER BY g_order,' . $this->connection->translate_field_ref('g_name');
        if (running_script('upgrader')) {
            $sup = '';
        }
        static $cnt_cache = array();
        if (isset($cnt_cache[$where]) && !running_script('install')) {
            $count = $cnt_cache[$where];
        } else {
            $count = persistent_cache_get('GROUPS_COUNT' . ($only_permissive ? '_PO' : ''));
            if ($count === null) {
                $groups_count_sql = 'SELECT COUNT(*) FROM ' . $this->connection->get_table_prefix() . 'f_groups g' . $where;
                $count = $this->connection->query_value_if_there($groups_count_sql, running_script('install')/*maybe no table yet*/, true);
                $cnt_cache[$where] = $count;
                persistent_cache_set('GROUPS_COUNT' . ($only_permissive ? '_PO' : ''), $cnt_cache[$where]);
            } else {
                $cnt_cache[$where] = $count;
            }
        }
        $too_many = ($count > 100) && ((!$force_show_all) || ($count > 4000));
        if ($too_many) {
            if ($for_member === null) {
                $for_member = get_member();
            }
            $where = ' WHERE g_is_private_club=0';
            if ($force_find === null) {
                $force_find = array();
            }
            $force_find = array_merge($force_find, $this->_get_members_groups($for_member));
            foreach ($force_find as $gid) {
                $where .= ' OR g.id=' . strval($gid);
            }
        }
        if (!function_exists('require_lang')) {
            require_code('lang');
        }
        $query = 'SELECT ' . $select . ' FROM ' . $this->connection->get_table_prefix() . 'f_groups g' . $where . $sup;
        static $rows_cache = array();
        $rows = mixed();
        if (!$too_many) {
            $rows = persistent_cache_get('GROUPS' . ($only_permissive ? '_PO' : ''));
        }
        if ($rows === null) {
            if (isset($rows_cache[$where]) && !running_script('install')) {
                $rows = $rows_cache[$where];
            } else {
                $rows = $this->connection->query($query, null, null, running_script('install')/*maybe no table yet*/, true, array('g_name' => 'SHORT_TRANS'));
                if (!is_array($rows)) {
                    $rows = array();
                }
                $rows_cache[$where] = $rows;
                if (!$too_many) {
                    persistent_cache_set('GROUPS' . ($only_permissive ? '_PO' : ''), $rows);
                }
            }
        }

        $out = array();
        foreach ($rows as $row) {
            if (($hide_hidden) && ($row['g_hidden'] == 1)) {
                $members_groups = function_exists('get_member') ? $GLOBALS['CNS_DRIVER']->get_members_groups(get_member()) : array();
                break;
            }
        }
        foreach ($rows as $row) {
            $name = get_translated_text($row['g_name'], $this->connection);

            if (($hide_hidden) && ($row['g_hidden'] == 1) && (!in_array($row['id'], $members_groups))) {
                if ($skip_hidden) {
                    continue;
                }
                $out[$row['id']] = do_lang('cns:SECRET_GROUP', strval($row['id']));
            } else {
                $out[$row['id']] = $name;
            }
        }
        return $out;
    }

    /**
     * Get the forum usergroup relating to the specified member ID.
     *
     * @param  MEMBER $member The member ID
     * @param  boolean $skip_secret Whether to skip looking at secret usergroups.
     * @param  boolean $handle_probation Whether to take probation into account
     * @return array The array of forum usergroups
     */
    protected function _get_members_groups($member, $skip_secret = false, $handle_probation = true)
    {
        require_code('cns_groups');
        return array_keys(cns_get_members_groups($member, $skip_secret, $handle_probation));
    }

    /**
     * Create a member login cookie.
     *
     * @param  MEMBER $id The member ID
     * @param  ?SHORT_TEXT $name The username (null: lookup)
     * @param  string $password The password
     */
    public function forum_create_cookie($id, $name, $password)
    {
        // User
        cms_setcookie(get_member_cookie(), strval($id));

        // Password
        $password_hashed_salted = $this->get_member_row_field($id, 'm_pass_hash_salted');
        $password_compat_scheme = $this->get_member_row_field($id, 'm_password_compat_scheme');
        if ($password_compat_scheme == 'plain') {
            $password_hashed_salted = md5($password_hashed_salted); // can't do direct representation for this, would be a plain text cookie; so in forum_authorise_login we expect it to be md5'd and compare thusly (as per non-cookie call to that function)
        }
        cms_setcookie(get_pass_cookie(), $password_hashed_salted);
    }

    /**
     * The hashing algorithm of this forum driver.
     *
     * @param  string $password The data to hash (the password in actuality)
     * @param  SHORT_TEXT $username The username
     * @return string The hashed data
     */
    public function forum_md5($password, $username)
    {
        require_code('cns_members');

        $member_id = $this->get_member_from_username($username);
        if (((is_null($GLOBALS['LDAP_CONNECTION'])) || (!cns_is_on_ldap($username))) && (is_null($member_id))) {
            $member_id = $this->connection->query_select_value_if_there('f_members', 'id', array('m_email_address' => $username));
            if (is_null($member_id)) {
                return '!'; // Invalid user logging in
            }
        }

        if ((!cns_is_ldap_member($member_id)) && (!is_null($member_id))) {
            return md5($password);
        } else {
            return $password; //cns_ldap_hash($member_id, $password); Can't do hash checks under all systems
        }
    }

    /**
     * Find if the given member ID and password is valid. If username is null, then the member ID is used instead.
     * All authorisation, cookies, and form-logins, are passed through this function.
     * Some forums do cookie logins differently, so a Boolean is passed in to indicate whether it is a cookie login.
     *
     * @param  ?SHORT_TEXT $username The member username (null: don't use this in the authentication - but look it up using the ID if needed)
     * @param  ?MEMBER $userid The member ID (null: use member name)
     * @param  SHORT_TEXT $password_hashed The md5-hashed password
     * @param  string $password_raw The raw password
     * @param  boolean $cookie_login Whether this is a cookie login, determines how the hashed password is treated for the value passed in
     * @return array A map of 'id' and 'error'. If 'id' is null, an error occurred and 'error' is set
     */
    public function forum_authorise_login($username, $userid, $password_hashed, $password_raw, $cookie_login = false)
    {
        require_code('cns_forum_driver_helper_auth');
        return _forum_authorise_login($this, $username, $userid, $password_hashed, $password_raw, $cookie_login);
    }

    /**
     * Handle flood control for members, and update member last visiting times.
     *
     * @param  MEMBER $id The member ID that just got detected
     */
    public function cns_flood_control($id)
    {
        global $FLOOD_CONTROL_ONCE;
        if ($FLOOD_CONTROL_ONCE) {
            return;
        }
        $FLOOD_CONTROL_ONCE = true;

        if ($GLOBALS['IS_VIA_BACKDOOR']) {
            return;
        }

        // Set last visit time session cookie if it doesn't exist
        if ((!isset($_COOKIE['last_visit'])) && (!is_guest($id))) {
            require_code('users_active_actions');
            $lvt = $this->get_member_row_field($id, 'm_last_visit_time');
            if (function_exists('cms_setcookie')) {// May be trying to check in safe mode when doing above require_code, so recurse
                cms_setcookie('last_visit', is_null($lvt) ? strval(time()) : strval($lvt), true);
            }
            $new_visit = true;
        } else {
            $new_visit = false;
        }

        // Do some flood control
        $submitting = ((has_interesting_post_fields()) && (get_param_string('type', null) !== 'edit') && (get_param_string('type', null) !== 'edit_category') && (!running_script('preview')));
        if ((get_value('no_flood_control') !== '1') && (running_script('index'))) {
            $restrict = $submitting ? 'flood_control_submit_secs' : 'flood_control_access_secs';
            $restrict_setting = $submitting ? 'm_last_submit_time' : 'm_last_visit_time';
            require_code('cns_groups');
            $restrict_answer = cns_get_best_group_property($this->get_members_groups($id), $restrict);
            if ((!$submitting) && (array_key_exists('redirect', $_GET))) {
                $restrict_answer = 0;
            }
            if ($restrict_answer < 0) {
                $restrict_answer = 0;
            }
            if (($restrict_answer == 0) && (is_guest($id))) {
                return;
            }
            $last = $this->get_member_row_field($id, $restrict_setting);
            if ($last > time()) {
                $last = time() - $restrict_answer; // Weird clock problem
            }
            $wait_time = $restrict_answer - time() + $last;
            if (($wait_time > 0) && (addon_installed('stats'))) {
                // Don't do flood control in every situation
                if (get_page_name() == 'join') {
                    return; // Not when joining (too early to be annoying!)
                }
                if ((!running_script('index')) && (!running_script('iframe'))) {
                    return; // Not when probably running some AJAX script
                }
                if (addon_installed('captcha')) {
                    $captcha = post_param_string('captcha', '');
                    if ($captcha != '') { // Don't consider a CAPTCHA submitting, it'll drive people nuts to get flood control right after a CAPTCHA
                        require_code('captcha');
                        if (check_captcha($captcha, false)) {
                            return;
                        }
                    }
                }

                require_code('site');
                log_stats('/flood', 0);

                $time_threshold = 30;
                $count_threshold = 50;
                $query = 'SELECT COUNT(*) FROM ' . $GLOBALS['SITE_DB']->get_table_prefix() . 'stats WHERE date_and_time>' . strval(time() - $time_threshold) . ' AND date_and_time<' . strval(time()) . ' AND ' . db_string_equal_to('ip', get_ip_address());
                $count = $GLOBALS['SITE_DB']->query_value_if_there($query);
                if (($count >= $count_threshold) && (addon_installed('securitylogging'))) {
                    $ip = get_ip_address();
                    require_code('failure');
                    require_code('failure_spammers');
                    add_ip_ban($ip, do_lang('SPAM_REPORT_SITE_FLOODING'));
                    require_code('notifications');
                    dispatch_notification('auto_ban', null, do_lang('AUTO_BAN_SUBJECT', $ip, null, null, get_site_default_lang()), do_notification_lang('AUTO_BAN_DOS_MESSAGE', $ip, integer_format($count_threshold), integer_format($time_threshold), get_site_default_lang()), null, A_FROM_SYSTEM_PRIVILEGED);
                    syndicate_spammer_report($ip, is_guest() ? '' : $GLOBALS['FORUM_DRIVER']->get_username(get_member()), $GLOBALS['FORUM_DRIVER']->get_member_email_address(get_member()), do_lang('SPAM_REPORT_SITE_FLOODING'));
                }
                if (!function_exists('require_lang')) {
                    require_code('lang');
                }
                if (!function_exists('do_lang_tempcode')) {
                    require_code('tempcode');
                }
                require_lang('cns');

                require_code('global3');
                set_http_status_code('429');

                warn_exit(do_lang_tempcode('FLOOD_CONTROL_RESTRICT', escape_html(integer_format($wait_time))));
            }
        } else {
            $restrict_answer = 0;
            $wait_time = 0;
        }

        $seconds_since_last_visit = time() - $this->get_member_row_field($id, 'm_last_visit_time');
        if ($seconds_since_last_visit < 0) {
            $seconds_since_last_visit = 0; // can happen if system clock changes
        }
        if ($restrict_answer != 0) {
            if (is_guest($id)) { // bit of a hack, so that guests don't trip each others limits. Works out statistically.
                if (get_option('session_prudence') == '0') {
                    global $SESSION_CACHE;
                    $num_guests = 0;
                    foreach ($SESSION_CACHE as $c) {
                        if (!array_key_exists('member_id', $c)) {
                            continue; // Workaround to HHVM weird bug
                        }

                        if (($c['last_activity'] > time() - 60 * 4) && (is_guest($c['member_id']))) {
                            $num_guests++;
                        }
                    }
                    $seconds_since_last_visit *= $num_guests;
                } else {
                    $restrict_answer = 0;
                }
            }
        }
        if (($restrict_answer != 0) || ($seconds_since_last_visit > 180) || ($new_visit)) {
            $_min_lastvisit_frequency = get_value('min_lastvisit_frequency');
            $min_lastvisit_frequency = is_null($_min_lastvisit_frequency) ? 0 : intval($_min_lastvisit_frequency);
            if (($submitting) || ((!$submitting) && ($seconds_since_last_visit > $wait_time/*don't want a flood control message to itself bump the last-visit time*/) && ($seconds_since_last_visit > $min_lastvisit_frequency))) {
                $old_ip = $this->get_member_row_field($id, 'm_ip_address');

                $change_map = array('m_last_visit_time' => time());
                if (get_ip_address() != $old_ip) {
                    $change_map['m_ip_address'] = get_ip_address();
                }
                if ($submitting) {
                    $change_map['m_last_submit_time'] = time();
                }
                $change_map['m_password_change_code'] = ''; // Security, to stop resetting password when account actively in use (stops people planting reset bombs then grabbing the details much later)

                if (get_page_name() != 'lost_password') {
                    if (get_db_type() != 'xml') {
                        if (!$this->connection->table_is_locked('f_members')) {
                            $this->connection->query_update('f_members', $change_map, array('id' => $id), '', 1, null, false, true);
                        }
                    }
                }
            }
        }
    }

    /**
     * Get a first known IP address of the given member.
     *
     * @param  MEMBER $id The member ID
     * @return IP The IP address
     */
    public function get_member_ip($id)
    {
        return $this->get_member_row_field($id, 'm_ip_address');
    }

    /**
     * Gets a whole member row from the database.
     *
     * @param  MEMBER $member The member ID
     * @return ?array The member row (null: no such member)
     */
    public function get_member_row($member)
    {
        if (isset($this->MEMBER_ROWS_CACHED[$member])) {
            return $this->MEMBER_ROWS_CACHED[$member];
        }

        if (is_guest($member)) {
            $test = persistent_cache_get('get_member_row_guest');
            if ($test !== null) {
                $this->MEMBER_ROWS_CACHED[$member] = $test;
                return $test;
            }
        }

        $rows = $this->connection->query_select('f_members m LEFT JOIN ' . $this->connection->get_table_prefix() . 'f_member_custom_fields c ON c.mf_member_id=m.id', array('*'), array('id' => $member), '', 1);
        if (!array_key_exists(0, $rows)) {
            $this->MEMBER_ROWS_CACHED[$member] = null;
            return null;
        }
        if ($this->connection == $GLOBALS['FORUM_DB'] && !multi_lang_content()) {
            // Optimisation
            require_code('cns_members');
            global $MEMBER_CACHE_FIELD_MAPPINGS;
            $MEMBER_CACHE_FIELD_MAPPINGS[$member] = $rows[0];
        }

        $this->MEMBER_ROWS_CACHED[$member] = $rows[0];

        if (is_guest($member)) {
            persistent_cache_set('get_member_row_guest', $this->MEMBER_ROWS_CACHED[$member]);
        }

        return $this->MEMBER_ROWS_CACHED[$member];
    }

    /**
     * Gets a named field of a member row from the database.
     *
     * @param  MEMBER $member The member ID
     * @param  string $field The field identifier
     * @return mixed The field
     */
    public function get_member_row_field($member, $field)
    {
        $row = $this->get_member_row($member);
        return ($row === null) ? null : (isset($row[$field]) ? $row[$field] : null);
    }
}
