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

/*EXTRA FUNCTIONS: crypt*/

/**
 * Find whether a phpBB password should be checked using the PHP password API (password_hash/password_verify).
 *
 * @param  string $password_hash Password hash
 * @return boolean Whether it should
 *
 * @ignore
 */
function _phpbb_uses_php_password_api(string $password_hash) : bool
{
    return (substr($password_hash, 0, 10) == '$argon2id$') && defined('PASSWORD_ARGON2ID')/*password_hash supports PHP >=5.5 but argon2id only supports PHP >=7.3*/;
}

/**
 * phpBB: The crypt function/replacement.
 *
 * @param  string $password To encode
 * @param  string $setting Encode settings in special format
 * @return ~string The encoded output (false: error)
 * @ignore
 */
function _phpbb_hash_crypt(string $password, string $setting)
{
    $itoa64 = './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';

    $output = '*';

    if (substr($setting, 0, 3) == '$H$') {
        $count_log2 = strpos($itoa64, $setting[3]);

        if ($count_log2 < 7 || $count_log2 > 30) {
            return $output;
        }

        $count = 1 << $count_log2;
        $salt = substr($setting, 4, 8);

        if (strlen($salt) != 8) {
            return $output;
        }

        /**
         * We're kind of forced to use MD5 here since it's the only
         * cryptographic primitive available in all versions of PHP
         * currently in use.  To implement our own low-level crypto
         * in PHP would result in much worse performance and
         * consequently in lower iteration counts and hashes that are
         * quicker to crack (by non-PHP code).
         */
        $hash = pack('H*', md5($salt . $password));
        do {
            $hash = pack('H*', md5($hash . $password));
            --$count;
        } while ($count > 0);

        $output = substr($setting, 0, 12);
        $output .= _phpbb_hash_encode64($hash, 16, $itoa64);
    }

    if ((substr($setting, 0, 3) == '$2y') || (substr($setting, 0, 3) == '$2a$')) {
        $hash = $setting;
        $salt = substr($hash, 0, 29);
        $output = crypt($password, $salt);
        if (strlen($output) < 60) {
            return false;
        }
    }

    return $output;
}

/**
 * phpBB: Encode hash.
 *
 * @param  string $input To encode
 * @param  integer $count How many chars to encode
 * @param  string $itoa64 Lookup table used internally
 * @return string The encoded output
 *
 * @ignore
 */
function _phpbb_hash_encode64(string $input, int $count, string $itoa64) : string
{
    $output = '';
    $i = 0;

    do {
        $value = ord($input[$i]);
        $i++;
        $output .= $itoa64[$value & 0x3f];

        if ($i < $count) {
            $value |= ord($input[$i]) << 8;
        }

        $output .= $itoa64[($value >> 6) & 0x3f];

        if ($i >= $count) {
            $i++;
            break;
        }
        $i++;

        if ($i < $count) {
            $value |= ord($input[$i]) << 16;
        }

        $output .= $itoa64[($value >> 12) & 0x3f];

        if ($i >= $count) {
            $i++;
            break;
        }
        $i++;

        $output .= $itoa64[($value >> 18) & 0x3f];
    } while ($i < $count);

    return $output;
}

/**
 * Cleanup a phpBB post to match Comcode.
 * Handles phpBB's special post markup.
 *
 * @param  string $text The post
 * @param  ?array $attach_ids List of attachment IDs (null: do not include attachments)
 * @return string Cleaned post
 */
function _phpbb3_post_text_to_comcode(string $text, ?array $attach_ids = null) : string
{
    /*
    <t>                                         outer tag, no nested tags
    <r>                                         outer tag, has nested tags
    <s>                                         start bbcode tag
    <e>                                         end bbcode tag
    <E>                                         emoticon
    <ATTACHMENT filename="..." index="...">     attachment
    */

    $attach_ids_used = [];

    $emoticons = array_keys($GLOBALS['FORUM_DRIVER']->find_emoticons());
    $special_first_chars = ['[' => true, '<' => true];
    foreach ($emoticons as $emoticon) {
        $c = $emoticon[0];
        $special_first_chars[$c] = true;
    }

    $comcode = '';
    $xml_tag_stack = [];
    $len = strlen($text);
    $matches = [];
    for ($i = 0; $i < $len; $i++) {
        $c = $text[$i];

        if (isset($special_first_chars[$c])) {
            if (($c == '<') && (preg_match('#^<(/?)(\w+)(\s[^<>]*)?' . '>#', substr($text, $i), $matches) != 0)) {
                $upcoming_tag = $matches[2];
                if ($upcoming_tag != 't' && $upcoming_tag != 'r') {
                    if ($matches[1] == '/') {
                        // Closing
                        if (array_peek($xml_tag_stack) == $upcoming_tag) {
                            array_pop($xml_tag_stack);
                        }
                    } else {
                        // See if we need to remap an attachment tag
                        $matches2 = [];
                        if (($upcoming_tag == 'ATTACHMENT') && (preg_match('#^<ATTACHMENT[^<>]*><s>\[attachment=(\d+)\]</s>.*?<e>\[/attachment\]</e></ATTACHMENT>#', substr($text, $i), $matches2) != 0)) {
                            $index = intval($matches2[1]);
                            if (($attach_ids !== null) && (isset($attach_ids[$index]))) {
                                $comcode .= '[attachment]' . strval($attach_ids[$index]) . '[/attachment]';
                                $attach_ids_used[] = $attach_ids[$index];
                            }
                            $i += strlen($matches2[0]) - 1;
                            continue;
                        }

                        array_push($xml_tag_stack, $matches[2]);
                    }
                }

                $i += strlen($matches[0]) - 1;
                continue;
            }

            if ($c == '[') {
                if (substr($text, $i, 2) != '[/') {
                    // See if we need to hide a start tag
                    if (!in_array('s', $xml_tag_stack)) {
                        $comcode .= '[semihtml]&#91;[/semihtml]';
                        continue;
                    }
                } else {
                    // See if we need to hide an end tag
                    if (!in_array('e', $xml_tag_stack)) {
                        $comcode .= '[semihtml]&#91;[/semihtml]';
                        continue;
                    }
                }
            }

            if (!in_array('E', $xml_tag_stack)) {
                // See if we need to hide an emoticon, because an emoticon appears without it being marked up as one
                foreach ($emoticons as $emoticon) {
                    if (substr($text, $i, strlen($emoticon)) == $emoticon) {
                        $comcode .= '[semihtml]' . $emoticon . '[/semihtml]';
                        $i += strlen($emoticon) - 1;
                        continue 2;
                    }
                }
            }
        }

        $comcode .= $c;
    }

    // Append any remaining attachments
    if ($attach_ids !== null) {
        foreach ($attach_ids as $attach_id) {
            if (($attach_id !== null) && (!in_array($attach_id, $attach_ids_used))) {
                $comcode .= '[attachment]' . strval($attach_id) . '[/attachment]';
            }
        }
    }

    $comcode = preg_replace('#\[size="?(\d+)"?\]#i', '[size="${1}%"]', $comcode);

    $comcode = str_replace('{', '\{', $comcode);

    return $comcode;
}

/**
 * Forum driver class.
 *
 * @package core_forum_drivers
 */
class Forum_driver_phpbb3 extends Forum_driver_base
{
    /**
     * Constructor.
     */
    public function __construct()
    {
        $version_required = '7.3';
        if (version_compare(PHP_VERSION, $version_required, '<')) {
            warn_exit(do_lang('PHP_TOO_OLD', $version_required));
        }
    }

    /**
     * Check the connected DB is valid for this forum driver.
     *
     * @return boolean Whether it is valid
     */
    public function check_db() : bool
    {
        $test = $this->db->query('SELECT COUNT(*) FROM ' . $this->db->get_table_prefix() . 'users', null, 0, true); // Suppress errors as we're checking to see if the table is there
        return $test !== null;
    }

    /**
     * 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(int $limit) : array
    {
        return $this->db->query('SELECT * FROM ' . $this->db->get_table_prefix() . 'users WHERE user_id<>' . strval($this->get_guest_id()) . ' ORDER BY user_posts DESC', $limit);
    }

    /**
     * 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_id The member who's language needs to be fetched
     * @return ?LANGUAGE_NAME The member's language (null: unknown)
     */
    public function get_lang(int $member_id) : ?string
    {
        return $this->get_member_row_field($member_id, 'user_lang');
    }

    /**
     * Find the member ID of the forum guest member.
     *
     * @return MEMBER The member ID of the forum guest member
     */
    public function get_guest_id() : int
    {
        return 1;
    }

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

    /**
     * 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, e.g. cannot be deleted
     * @param  BINARY $viewable Whether the field is for viewing by the owner and the public (0: cannot be viewed unless the viewing member has view_any_profile_field privilege)
     * @param  BINARY $settable Whether the field is for setting by the owner
     * @param  BINARY $required Whether the field is required
     * @param  string $description Description
     * @param  string $type The field type (it's the same as the software's field types, although we only expect forum drivers to specifically support short_text/long_text/integer/float and for the rest to be mapped to long_text)
     * @param  BINARY $encrypted Whether the field is encrypted
     * @param  ?string $default Default field value (null: standard for field type)
     * @param  SHORT_TEXT $options Field options
     * @param  BINARY $include_in_main_search Whether to include in main keyword search
     * @param  BINARY $allow_template_search Whether to allow template search
     * @param  ID_TEXT $icon An icon to show the CPF with on the member profiles
     * @param  ID_TEXT $section A section to show with on the member-links part of member profiles
     * @param  LONG_TEXT $tempcode This is Tempcode that is used for displaying the field. See the DESCRIPTION_CPF_CODE language string.
     * @param  ID_TEXT $autofill_type Autofill field name from https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofill-field
     * @param  ID_TEXT $autofill_hint Autofill hint: '' or 'shipping' or 'billing'
     * @return boolean Whether the custom field was created successfully
     */
    public function install_create_custom_field(string $name, int $length, int $locked = 1, int $viewable = 0, int $settable = 0, int $required = 0, string $description = '', string $type = 'long_text', int $encrypted = 0, ?string $default = null, string $options = '', int $include_in_main_search = 0, int $allow_template_search = 0, string $icon = '', string $section = '', string $tempcode = '', string $autofill_type = '', string $autofill_hint = '') : bool
    {
        list($db_type, $db_default) = $this->remap_cms_field_type_to_db_type($type, $default);
        $query = $this->db->driver->add_table_field__sql($this->db->get_table_prefix() . 'users', 'cms_' . $name, $db_type, $db_default);
        $this->db->query($query, null, 0, true); // Suppress errors in case field already exists
        return true;
    }

    /**
     * Edit the specified custom field in the forum (some forums implemented this using proper Custom Profile Fields, others through adding a new field).
     *
     * @param  string $old_name The name of the existing custom field to edit
     * @param  string $name The new name of the custom field
     * @param  integer $length The length of the custom field (ignored for Conversr, $type used instead)
     * @param  BINARY $locked Whether the field is locked, e.g. cannot be deleted
     * @param  BINARY $viewable Whether the field is for viewing by the owner and the public (0: cannot be viewed unless the viewing member has view_any_profile_field privilege)
     * @param  BINARY $settable Whether the field is for setting by the owner
     * @param  BINARY $required Whether the field is required
     * @param  string $description Description
     * @param  string $type The field type (it's the same as the software's field types, although we only expect forum drivers to specifically support short_text/long_text/integer/float and for the rest to be mapped to long_text)
     * @param  BINARY $encrypted Whether the field is encrypted
     * @param  ?string $default Default field value (null: standard for field type)
     * @param  SHORT_TEXT $options Field options
     * @param  BINARY $include_in_main_search Whether to include in main keyword search
     * @param  BINARY $allow_template_search Whether to allow template search
     * @param  ID_TEXT $icon An icon to show the CPF with on the member profiles
     * @param  ID_TEXT $section A section to show with on the member-links part of member profiles
     * @param  LONG_TEXT $tempcode This is Tempcode that is used for displaying the field. See the DESCRIPTION_CPF_CODE language string.
     * @param  ID_TEXT $autofill_type Autofill field name from https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofill-field
     * @param  ID_TEXT $autofill_hint Autofill hint: '' or 'shipping' or 'billing'
     * @return boolean Whether the custom field was edited successfully
     */
    public function install_edit_custom_field(string $old_name, string $name, int $length, int $locked = 1, int $viewable = 0, int $settable = 0, int $required = 0, string $description = '', string $type = 'long_text', int $encrypted = 0, ?string $default = null, string $options = '', int $include_in_main_search = 0, int $allow_template_search = 0, string $icon = '', string $section = '', string $tempcode = '', string $autofill_type = '', string $autofill_hint = '') : bool
    {
        list($db_type) = $this->remap_cms_field_type_to_db_type($type, $default);
        $query = $this->db->driver->alter_table_field($this->db->get_table_prefix() . 'users', 'cms_' . $old_name, $db_type, 'cms_' . $name);
        $this->db->query($query, null, 0, true); // Suppress errors in case field already edited
        return true;
    }

    /**
     * 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() : array
    {
        global $PROBED_FORUM_CONFIG;
        $a = [];
        $a['name'] = 'phpbb_table_prefix';
        $a['default'] = array_key_exists('sql_tbl_prefix', $PROBED_FORUM_CONFIG) ? $PROBED_FORUM_CONFIG['sql_tbl_prefix'] : 'phpbb_';
        $a['description'] = do_lang('MOST_DEFAULT');
        $a['title'] = 'phpBB ' . do_lang('TABLE_PREFIX');
        return [$a];
    }

    /**
     * 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(string $path) : bool
    {
        global $PROBED_FORUM_CONFIG;
        if (@file_exists($path . '/config.php')) {
            global $FILE_BASE;
            $cms_path = str_replace('\\', '/', $FILE_BASE);
            $path = str_replace('\\', '/', $path);
            $lcs = ''; // longest common substring
            for ($i = 0; $i < min(strlen($cms_path), strlen($path)); $i++) {
                if ($cms_path[$i] == $path[$i]) {
                    $lcs .= $path[$i];
                } else {
                    break;
                }
            }
            $str_diff_forum = substr($path, strlen($lcs));
            $str_diff_site = substr($cms_path, strlen($lcs));

            $base_url = get_base_url();
            if (substr($base_url, -strlen($str_diff_site)) == $str_diff_site) { // hopefully what didn't make it in the lcs is on the end of the base URL - allowing us to get a lock
                $base_url = substr($base_url, 0, strlen($base_url) - strlen($str_diff_site)) . $str_diff_forum;
            } else {
                $base_url = '';
            }

            $dbhost = '';
            $dbname = '';
            $dbuser = '';
            $dbpasswd = '';
            $table_prefix = '';
            @include($path . '/config.php');
            $PROBED_FORUM_CONFIG['sql_host'] = $dbhost;
            $PROBED_FORUM_CONFIG['sql_database'] = $dbname;
            $PROBED_FORUM_CONFIG['sql_user'] = $dbuser;
            $PROBED_FORUM_CONFIG['sql_pass_exists'] = ($dbpasswd != '');
            $PROBED_FORUM_CONFIG['sql_tbl_prefix'] = $table_prefix;
            $PROBED_FORUM_CONFIG['board_url'] = $base_url;

            $tmp = new DatabaseConnector($dbname, $dbhost, $dbuser, $dbpasswd, $table_prefix, true);
            $cookie_name = $tmp->query_select_value_if_there('config', 'config_value', ['config_name' => 'cookie_name'], '', true);
            if (!empty($cookie_name)) {
                $PROBED_FORUM_CONFIG['cookie_member_id'] = $cookie_name . '_u';
                $PROBED_FORUM_CONFIG['cookie_member_hash'] = $cookie_name . '_k';
            } else {
                $PROBED_FORUM_CONFIG['cookie_member_id'] = 'phpbb_u';
                $PROBED_FORUM_CONFIG['cookie_member_hash'] = 'phpbb_k';
            }

            return true;
        }
        return false;
    }

    /**
     * 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() : array
    {
        return [
            'phpBB',
            'phpBB3',
            'phpbb',
            'phpbb3',
            'forums',
            'forum',
            'boards',
            'board',
            '../forums/phpBB',
            '../forums/phpBB3',
            '../forums/phpbb',
            '../forums/phpbb3',
            '../phpBB',
            '../phpBB3',
            '../phpbb',
            '../phpbb3',
            '../forums',
            '../forum',
            '../boards',
            '../board',
        ];
    }

    /**
     * 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(string $field_name = 'post') : object
    {
        require_code('comcode_compiler');
        $emoticons = $this->db->query_select('smilies', ['code'], [], 'GROUP BY code');
        $em = new Tempcode();
        foreach ($emoticons as $emo) {
            $code = $emo['code'];
            $em->attach(do_template('EMOTICON_CLICK_CODE', ['_GUID' => '6eef1777ed25b62f2c47964eebb655ac', 'FIELD_NAME' => $field_name, 'CODE' => $code, 'IMAGE' => apply_emoticons($code)]));
        }

        return $em;
    }

    /**
     * Pin a topic.
     *
     * @param  AUTO_LINK $topic_id The topic ID
     * @param  boolean $pin True: pin it, False: unpin it
     */
    public function pin_topic(int $topic_id, bool $pin = true)
    {
        $this->db->query_update('topics', ['topic_type' => $pin ? 1 : 0], ['topic_id' => $topic_id], '', 1);
    }

    /**
     * Set a Custom Profile Field's value, if the custom field exists. Only works on specially-named (titled) fields.
     *
     * @param  MEMBER $member_id The member ID
     * @param  string $field The field name (e.g. "firstname" for the CPF with a title of "cms_firstname")
     * @param  string $value The value
     */
    public function set_custom_field(int $member_id, string $field, string $value)
    {
        $this->db->query_update('users', ['cms_' . $field => $value], ['user_id' => $member_id], '', null, 0, false, true); // Errors suppressed in case field gone missing
    }

    /**
     * Get Custom Profile Fields values for all 'cms_' prefixed keys.
     *
     * @param  MEMBER $member_id The member ID
     * @return ?array A map of the Custom Profile Fields, key_suffix=>value (null: no fields)
     */
    public function get_custom_fields(int $member_id) : ?array
    {
        $row = $this->get_member_row($member_id);
        if ($row === null) {
            return null;
        }

        $out = [];
        foreach ($row as $attribute => $value) {
            if (substr($attribute, 0, 4) == 'cms_') {
                $out[substr($attribute, 4)] = $value;
            }
        }
        return $out;
    }

    /**
     * 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_primary_group(array $r) : int
    {
        $id = $r['user_id'];

        // The first usergroup they're joined to
        $group = $this->db->query_select_value_if_there('user_group u LEFT JOIN ' . $this->db->get_table_prefix() . 'groups g ON u.group_id=g.group_id', 'g.group_id AS group_id', ['user_id' => $id, 'user_pending' => 0]);
        return $group;
    }

    /**
     * From a member row, get the member's member ID.
     *
     * @param  array $r The profile-row
     * @return MEMBER The member ID
     */
    public function mrow_member_id(array $r) : int
    {
        return $r['user_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(array $r) : int
    {
        return $r['user_lastvisit'];
    }

    /**
     * From a member row, get the member's name.
     *
     * @param  array $r The profile-row
     * @return string The username
     */
    public function mrow_username(array $r) : string
    {
        return $r['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_address(array $r) : string
    {
        return $r['user_email'];
    }

    /**
     * Get a URL to the specified member's home (control panel).
     *
     * @param  MEMBER $member_id The member ID
     * @return URLPATH The URL to the members home
     */
    public function member_home_url(int $member_id) : string
    {
        return get_forum_base_url() . '/memberlist.php?mode=editprofile';
    }

    /**
     * Get the photo URL for the specified member ID.
     *
     * @param  MEMBER $member_id The member ID
     * @param  boolean $full Get full photo
     * @return URLPATH The URL (blank: none)
     */
    public function get_member_photo_url(int $member_id, bool $full = false) : string
    {
        return '';
    }

    /**
     * Get the avatar URL for the specified member ID.
     *
     * @param  MEMBER $member_id The member ID
     * @param  boolean $fallback_support Whether fallback support should be allowed (passed by reference)
     * @return URLPATH The URL (blank: none)
     */
    protected function _get_member_avatar_url(int $member_id, bool &$fallback_support) : string
    {
        $avatar_gallery_path = $this->db->query_select_value('config', 'config_value', ['config_name' => 'avatar_gallery_path']);

        $type = $this->get_member_row_field($member_id, 'user_avatar_type');

        $filename = $this->get_member_row_field($member_id, 'user_avatar');

        switch ($type) {
            case 'avatar.driver.upload':
                return get_forum_base_url() . '/download/file.php?avatar=' . $filename;
            case 'avatar.driver.remote':
                return $filename;
            case 'avatar.driver.local':
                return get_forum_base_url() . '/' . $avatar_gallery_path . '/' . $filename;
            case 'avatar.driver.gravatar':
                return find_script('gravatar') . '?id=' . strval($member_id) . '&from_driver=1';
        }
        return '';
    }

    /**
     * Get a URL to the specified member's profile.
     *
     * @param  MEMBER $member_id The member ID
     * @return URLPATH The URL to the member profile
     */
    protected function _member_profile_url(int $member_id) : string
    {
        return get_forum_base_url() . '/memberlist.php?mode=viewprofile&u=' . strval($member_id);
    }

    /**
     * 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() : string
    {
        return get_forum_base_url() . '/ucp.php?mode=register';
    }

    /**
     * Get a URL to the members-online page.
     *
     * @return URLPATH The URL to the members-online page
     */
    protected function _users_online_url() : string
    {
        return get_forum_base_url() . '/viewonline.php';
    }

    /**
     * Get a URL to send a private/personal message to the given member.
     *
     * @param  MEMBER $member_id The member ID
     * @return URLPATH The URL to the private/personal message page
     */
    protected function _member_pm_url(int $member_id) : string
    {
        return get_forum_base_url() . '/ucp.php?i=pm&mode=compose&u=' . strval($member_id);
    }

    /**
     * Get a URL to the specified forum.
     *
     * @param  integer $forum_id The forum ID
     * @return URLPATH The URL to the specified forum
     */
    protected function _forum_url(int $forum_id) : string
    {
        return get_forum_base_url() . '/viewforum.php?f=' . strval($forum_id);
    }

    /**
     * Get the forum ID from a forum name. Numeric forum names are considered to be usable as IDs.
     *
     * @param  SHORT_TEXT $forum_name The forum name
     * @return ?integer The forum ID (null: not found)
     */
    public function forum_id_from_name(string $forum_name) : ?int
    {
        return is_numeric($forum_name) ? intval($forum_name) : $this->db->query_select_value_if_there('forums', 'forum_id', ['forum_name' => $forum_name]);
    }

    /**
     * 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 post 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 not validated 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 not validated 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
     * @return array Topic ID (may be null), and whether a hidden post has been made
     */
    public function make_post_forum_topic(string $forum_name, string $topic_identifier, int $member_id, string $post_title, string $_post, string $content_title, string $topic_identifier_encapsulation_prefix, ?string $content_url = null, ?int $time = null, ?string $ip = null, ?int $validated = null, ?int $topic_validated = 1, bool $skip_post_checks = false, string $poster_name_if_guest = '', ?int $parent_id = null, bool $staff_only = false) : array
    {
        $__post = comcode_to_tempcode($_post);
        $post = $__post->evaluate();

        if ($time === null) {
            $time = time();
        }
        if ($ip === null) {
            $ip = get_ip_address();
        }
        $forum_id = $this->forum_id_from_name($forum_name);
        if ($forum_id === null) {
            warn_exit(do_lang_tempcode('MISSING_FORUM', escape_html($forum_name)), false, true);
        }
        $topic_id = $this->find_topic_id_for_topic_identifier($forum_name, $topic_identifier);
        $local_ip = '127.0.0.1';
        $is_new = ($topic_id === null);
        if ($is_new) {
            $map = ['forum_id' => $forum_id, 'topic_title' => $content_title . ', ' . $topic_identifier_encapsulation_prefix . ': #' . $topic_identifier, 'topic_poster' => $member_id, 'topic_first_poster_name' => $this->get_username($member_id), 'topic_last_poster_name' => $this->get_username($member_id), 'topic_time' => $time, 'topic_last_post_time' => $time, 'topic_views' => 0, 'topic_status' => 0, 'topic_visibility' => ($validated === 0) ? 0 : 1, 'topic_type' => 0, 'topic_first_post_id' => 0, 'topic_last_post_id' => 0, 'topic_moved_id' => 0];
            $topic_id = $this->db->query_insert('topics', $map, true);

            $home_link = '[url]' . $content_url . '[/url]';
            $map = ['topic_id' => $topic_id, 'forum_id' => $forum_id, 'poster_id' => -1, 'post_text' => do_lang('SPACER_POST', $home_link, '', '', get_site_default_lang()), 'post_subject' => $content_title . ', ' . $topic_identifier_encapsulation_prefix . ': #' . $topic_identifier, 'post_time' => $time, 'poster_ip' => $local_ip, 'post_username' => do_lang('SYSTEM'), 'enable_bbcode' => 1, 'enable_smilies' => 1, 'enable_sig' => 1, 'post_edit_time' => 0, 'post_edit_count' => 0, 'post_visibility' => 1];
            $post_id = $this->db->query_insert('posts', $map, true);
            $this->db->query_update('topics', ['topic_first_post_id' => $post_id], ['topic_id' => $topic_id], '', 1);
            $this->db->query_update('forums', ['forum_last_post_subject' => do_lang('SPACER_POST', $home_link, '', '', get_site_default_lang())], ['forum_id' => $forum_id], '', 1);
            $this->db->query('UPDATE ' . $this->db->get_table_prefix() . 'forums SET forum_topics_approved=(forum_topics_approved+1),forum_posts_approved=(forum_posts_approved+1) WHERE forum_id=' . strval($forum_id), 1);
        }

        $GLOBALS['LAST_TOPIC_ID'] = $topic_id;
        $GLOBALS['LAST_TOPIC_IS_NEW'] = $is_new;

        if ($post == '') {
            return [$topic_id, false];
        }

        $map = ['topic_id' => $topic_id, 'forum_id' => $forum_id, 'poster_id' => $member_id, 'post_text' => $post, 'post_time' => $time, 'poster_ip' => $ip, 'post_username' => $this->get_username($member_id), 'enable_bbcode' => 1, 'enable_smilies' => 1, 'enable_sig' => 1, 'post_edit_time' => 0, 'post_edit_count' => 0, 'post_visibility' => 1];
        $post_id = $this->db->query_insert('posts', $map, true);
        $this->db->query('UPDATE ' . $this->db->get_table_prefix() . 'forums SET forum_posts_approved=(forum_posts_approved+1), forum_last_post_id=' . strval($post_id) . ' WHERE forum_id=' . strval($forum_id), 1);
        $this->db->query('UPDATE ' . $this->db->get_table_prefix() . 'topics SET topic_posts_approved=(topic_posts_approved+1), topic_last_post_id=' . strval($post_id) . ' WHERE topic_id=' . strval($topic_id), 1);

        $this->db->query_update('forums', ['forum_last_poster_id' => $member_id, 'forum_last_poster_name' => $this->get_username($member_id), 'forum_last_post_time' => $time], ['forum_id' => $forum_id], '', 1);
        $this->db->query_update('topics', ['topic_last_poster_name' => $this->get_username($member_id), 'topic_last_post_time' => $time], ['topic_id' => $topic_id], '', 1);

        return [$topic_id, false];
    }

    /**
     * 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 (null: do not gather it)
     * @param  ?integer $max Maximum comments to returned (null: no limit)
     * @param  integer $start Comment to start at
     * @param  boolean $mark_read Whether to mark the topic read (ignored for this forum driver)
     * @param  boolean $reverse Whether to show in reverse
     * @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(int $topic_id, ?int &$count = null, ?int $max = 100, int $start = 0, bool $mark_read = true, bool $reverse = false)
    {
        if ($topic_id === null) {
            return (-2);
        }
        $order = $reverse ? 'post_time DESC' : 'post_time';
        $rows = $this->db->query('SELECT * FROM ' . $this->db->get_table_prefix() . 'posts p WHERE topic_id=' . strval($topic_id) . ' AND post_text NOT LIKE \'' . db_encode_like(substr(do_lang('SPACER_POST', '', '', '', get_site_default_lang()), 0, 20) . '%') . '\' ORDER BY ' . $order, $max, $start);
        if ($count !== null) {
            $count = $this->db->query_value_if_there('SELECT COUNT(*) FROM ' . $this->db->get_table_prefix() . 'posts p WHERE topic_id=' . strval($topic_id) . ' AND post_text NOT LIKE \'' . db_encode_like(substr(do_lang('SPACER_POST', '', '', '', get_site_default_lang()), 0, 20) . '%') . '\'');
        }
        $out = [];
        foreach ($rows as $myrow) {
            $temp = [];
            $temp['title'] = $myrow['post_subject'];
            if ($temp['title'] === null) {
                $temp['title'] = '';
            }
            push_lax_comcode(true);
            $temp['message'] = comcode_to_tempcode(_phpbb3_post_text_to_comcode($myrow['post_text']), $myrow['poster_id']);
            pop_lax_comcode();
            $temp['member'] = $myrow['poster_id'];
            $temp['date'] = $myrow['post_time'];

            $out[] = $temp;
        }

        return $out;
    }

    /**
     * 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 $topic_id The topic ID
     * @param  string $forum The forum ID
     * @return URLPATH The URL to the topic
     */
    public function topic_url(int $topic_id, string $forum) : string
    {
        return get_forum_base_url() . '/viewtopic.php?t=' . strval($topic_id);
    }

    /**
     * Get a URL to the specified post ID.
     *
     * @param  integer $post_id The post ID
     * @param  ?mixed $forum The forum ID (null: private topic)
     * @return URLPATH The URL to the post
     */
    public function post_url(int $post_id, $forum) : string
    {
        $topic_id = $this->db->query_select_value_if_there('posts', 'tid', ['pid' => $post_id]);
        if ($topic_id === null) {
            return '?';
        }
        $url = get_forum_base_url() . '/viewtopic.php?t=' . strval($topic_id) . '#' . strval($post_id);
        return $url;
    }

    /**
     * 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
     * @return ?integer The topic ID (null: not found)
     */
    public function find_topic_id_for_topic_identifier(string $forum, string $topic_identifier) : ?int
    {
        if (is_integer($forum)) {
            $forum_id = intval($forum);
        } else {
            $forum_id = $this->forum_id_from_name($forum);
        }
        if ($forum_id === null) {
            return null;
        }
        return $this->db->query_value_if_there('SELECT topic_id FROM ' . $this->db->get_table_prefix() . 'topics WHERE forum_id=' . strval($forum_id) . ' AND (' . db_string_equal_to('topic_title', $topic_identifier) . ' OR topic_title LIKE \'' . db_encode_like('%: #' . $topic_identifier) . '\')');
    }

    /**
     * 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 forum ID 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 (blank: no filter)
     * @param  SHORT_TEXT $filter_topic_description The topic description filter; may apply to the topic title if there is no separate description field with additional wildcarding to match what make_post_forum_topic is doing (blank: no 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  boolean $only_open Open topics only
     * @return ?array The array of topics (null: error)
     */
    public function show_forum_topics($name, int $limit, int $start, int &$max_rows, string $filter_topic_title = '', string $filter_topic_description = '', bool $show_first_posts = false, string $date_key = 'lasttime', bool $hot = false, bool $only_open = false) : ?array
    {
        // Build forum ID query
        if (is_integer($name)) { // Forum ID
            $id_list = 'forum_id=' . strval($name);
        } elseif (!is_array($name)) { // Forum name
            $id = $this->forum_id_from_name($name);
            if ($id === null) {
                return null;
            }
            $id_list = 'forum_id=' . strval($id);
        } else { // Array of forum IDs
            $id_list = '';
            foreach (array_keys($name) as $id) {
                if ($id_list != '') {
                    $id_list .= ' OR ';
                }
                $id_list .= 'forum_id=' . strval($id);
            }
            if ($id_list == '') {
                return null;
            }
        }

        $topic_filter = '';
        if ($filter_topic_title != '') {
            $topic_filter .= ' AND topic_title LIKE \'' . db_encode_like($filter_topic_title . ', %') . '\'';
        }
        if ($filter_topic_description != '') {
            $topic_filter .= ' AND topic_title LIKE \'' . db_encode_like('%, ' . $filter_topic_description) . '\'';
        }
        if ($only_open) {
            $topic_filter .= ' AND topic_status<>1 AND topic_visibility=1';
        }

        $rows = $this->db->query('SELECT * FROM ' . $this->db->get_table_prefix() . 'topics WHERE (' . $id_list . ')' . $topic_filter . ' ORDER BY ' . (($date_key == 'lasttime') ? 'topic_last_post_time' : 'topic_time') . ' DESC', $limit, $start);
        $max_rows = $this->db->query_value_if_there('SELECT COUNT(*) FROM ' . $this->db->get_table_prefix() . 'topics WHERE (' . $id_list . ')' . $topic_filter);

        // Generate output
        $out = [];
        foreach ($rows as $i => $r) {
            $out[$i] = [];
            $out[$i]['id'] = $r['topic_id'];
            $out[$i]['num'] = $r['topic_posts_approved'];
            $out[$i]['title'] = $r['topic_title'];
            $out[$i]['description'] = $r['topic_title'];
            $out[$i]['firsttime'] = $r['topic_time'];
            $out[$i]['firstusername'] = $this->get_username($r['topic_poster']);
            $out[$i]['firstmemberid'] = $r['topic_poster'];
            $out[$i]['closed'] = (($r['topic_status'] == 1) || ($r['topic_visibility'] == 0)) ? 1 : 0;
            $out[$i]['lastusername'] = $r['topic_last_poster_name'];
            $out[$i]['lastmemberid'] = $r['topic_last_poster_id'];
            $out[$i]['lasttime'] = $r['topic_last_post_time'];

            // Get first non-spacer post
            $fp_rows = $this->db->query('SELECT post_subject,post_text,bbcode_uid,poster_id,post_username,post_time FROM ' . $this->db->get_table_prefix() . 'posts p WHERE post_text NOT LIKE \'' . db_encode_like(substr(do_lang('SPACER_POST', '', '', '', get_site_default_lang()), 0, 20) . '%') . '\' AND topic_id=' . strval($r['topic_id']) . ' ORDER BY post_time ASC', 1);

            // Filter topics without a post
            if (!array_key_exists(0, $fp_rows)) {
                unset($out[$i]);
                continue;
            }

            // Fix title based on first non-spacer post
            $out[$i]['firsttitle'] = $fp_rows[0]['post_subject'];

            // Process first post for displaying if applicable
            if ($show_first_posts) {
                push_lax_comcode(true);
                $out[$i]['firstpost'] = comcode_to_tempcode(_phpbb3_post_text_to_comcode($fp_rows[0]['post_text']), $fp_rows[0]['poster_id']);
                pop_lax_comcode();
            }

            if ($i == $limit) {
                break;
            }
        }
        if (!empty($out)) {
            return $out;
        }

        return null;
    }

    /**
     * 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 array of members (null: no members)
     */
    public function member_group_query(array $groups, ?int $max = null, int $start = 0) : ?array
    {
        $_groups = '';
        foreach ($groups as $group) {
            if ($_groups != '') {
                $_groups .= ' OR ';
            }
            $_groups .= 'g.group_id=' . strval($group);
        }
        if ($_groups == '') {
            return [];
        }
        return $this->db->query('SELECT * FROM ' . $this->db->get_table_prefix() . 'user_group g LEFT JOIN ' . $this->db->get_table_prefix() . 'users u ON u.user_id=g.user_id WHERE (' . $_groups . ') AND user_pending=0 ORDER BY g.group_id ASC', $max, $start, false, true);
    }

    /**
     * Get rows of members before the given one.
     * It cannot be assumed there are no gaps in member IDs, as members may be deleted.
     *
     * @param  MEMBER $member_id The member ID to paginate back from
     * @param  integer $total Number of members to retrieve
     * @return array Member rows
     */
    public function get_previous_members(int $member_id, int $total = 1) : array
    {
        $sql = 'SELECT user_id FROM ' . $this->db->get_table_prefix() . 'users WHERE user_id>0';
        $sql .= ' AND user_id<' . strval($member_id);
        $sql .= ' ORDER BY user_id DESC';
        $rows = $this->db->query($sql, $total);
        return $rows;
    }

    /**
     * Get rows of members after the given one.
     * It cannot be assumed there are no gaps in member IDs, as members may be deleted.
     *
     * @param  ?MEMBER $member_id The member ID to increment (null: find the very first members)
     * @param  integer $total Number of members to retrieve
     * @return array Member rows
     */
    public function get_next_members(?int $member_id, int $total = 1) : array
    {
        $sql = 'SELECT user_id FROM ' . $this->db->get_table_prefix() . 'users WHERE user_id>0';
        if ($member_id !== null) {
            $sql .= ' AND user_id>' . strval($member_id);
        }
        $sql .= ' ORDER BY user_id DESC';
        $rows = $this->db->query($sql, $total);
        return $rows;
    }

    /**
     * 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(string $ip) : array
    {
        return $this->db->query_select('posts', ['DISTINCT poster_id AS id'], ['poster_ip' => $ip]);
    }

    /**
     * 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_id The member ID
     * @return ?SHORT_TEXT The username (null: member deleted)
     */
    protected function _get_username(int $member_id) : ?string
    {
        return $this->get_member_row_field($member_id, 'username');
    }

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

    /**
     * Find if this member may have e-mails sent to them.
     *
     * @param  MEMBER $member_id The member ID
     * @return boolean Whether the member may have e-mails sent to them
     */
    public function get_member_email_allowed(int $member_id) : bool
    {
        $v = $this->get_member_row_field($member_id, 'user_allow_viewemail');
        if ($v == 1) {
            return true;
        }
        return false;
    }

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

    /**
     * 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)
     * @return ?array The array of matched members (null: none found)
     */
    public function get_matching_members(string $pattern, ?int $limit = null) : ?array
    {
        $rows = $this->db->query('SELECT * FROM ' . $this->db->get_table_prefix() . 'users WHERE username_clean LIKE \'' . db_encode_like(cms_mb_strtolower($pattern)) . '\' AND user_id<>' . strval($this->get_guest_id()) . ' ORDER BY user_lastvisit DESC', $limit);
        sort_maps_by($rows, 'username');
        return $rows;
    }

    /**
     * Get the given member's post count.
     *
     * @param  MEMBER $member_id The member ID
     * @return integer The post count
     */
    public function get_post_count(int $member_id) : int
    {
        $c = $this->get_member_row_field($member_id, 'user_posts');
        if ($c === null) {
            $c = 0;
        }
        return $c;
    }

    /**
     * Get the given member's topic count.
     *
     * @param  MEMBER $member_id The member ID
     * @return integer The topic count
     */
    public function get_topic_count(int $member_id) : int
    {
        return $this->db->query_select_value('topics', 'COUNT(*)', ['topic_poster' => $member_id]);
    }

    /**
     * Find out if the given member ID is banned.
     *
     * @param  MEMBER $member_id The member ID
     * @param  ?ID_TEXT $reasoned_ban Ban reasoning returned by reference (null: none)
     * @return boolean Whether the member is banned
     */
    public function is_banned(int $member_id, ?string &$reasoned_ban = null) : bool
    {
        $reasoned_ban = $this->db->query_select_value_if_there('banlist', 'ban_give_reason', ['ban_userid' => $member_id]);
        if ($reasoned_ban !== null) {
            return true;
        }
        return false;
    }

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

    /**
     * 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.
     *
     * @return array The map
     */
    public function find_emoticons() : array
    {
        if ($this->EMOTICON_CACHE !== null) {
            return $this->EMOTICON_CACHE;
        }
        $rows = $this->db->query_select('smilies', ['*'], [], '', null, 0, true);
        if ($rows === null) {
            $rows = [];
        }
        $this->EMOTICON_CACHE = [];
        foreach ($rows as $myrow) {
            $src = $myrow['smiley_url'];
            if (url_is_local($src)) {
                $src = $this->get_emo_dir() . $src;
            }
            $this->EMOTICON_CACHE[$myrow['code']] = ['EMOTICON_IMG_CODE_DIR', $src, $myrow['code']];
        }
        return $this->EMOTICON_CACHE;
    }

    /**
     * Find a list of all forum skins (aka themes).
     *
     * @return array The list of skins
     */
    public function get_skin_list() : array
    {
        $table = 'styles';
        $codename = 'style_name';

        $rows = $this->db->query_select($table, [$codename]);
        return collapse_1d_complexity($codename, $rows);
    }

    /**
     * Try to find the theme that the logged-in/guest member is using, and map it to a site theme.
     * The themes/map.ini file functions to provide this mapping between forum themes, and site themes, and has a slightly different meaning for different forum drivers. For example, some drivers map the forum themes theme directory to the site theme name, while others made the humanly readable 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_id The member to find for (null: current member)
     * @return ID_TEXT The theme
     */
    public function _get_theme(bool $skip_member_specific = false, ?int $member_id = null) : string
    {
        $def = '';

        // Load in remapper
        require_code('files');
        $map = file_exists(get_file_base() . '/themes/map.ini') ? cms_parse_ini_file_fast(get_file_base() . '/themes/map.ini') : [];

        // Work out
        if (!$skip_member_specific) {
            if ($member_id === null) {
                $member_id = get_member();
            }
            if ($member_id > 0) {
                $skin = $this->get_member_row_field($member_id, 'user_style');
            } else {
                $skin = 0;
            }
            if ($skin > 0) { // User has a custom theme
                $phpbb = $this->db->query_select_value_if_there('styles', 'style_name', ['style_id' => $skin]);
                if ($phpbb !== null) {
                    $def = array_key_exists($phpbb, $map) ? $map[$phpbb] : $phpbb;
                }
            }
        }

        // Look for a skin according to our site name (we bother with this instead of 'default' because the software itself likes to never choose a theme when forum-theme integration is on: all forum [via map] or all site seems cleaner, although it is complex)
        if ((!(strlen($def) > 0)) || (!file_exists(get_custom_file_base() . '/themes/' . $def))) {
            $phpbb = $this->db->query_select_value_if_there('styles', 'style_name', ['style_name' => get_site_name()]);
            if ($phpbb !== null) {
                $def = array_key_exists($phpbb, $map) ? $map[$phpbb] : $phpbb;
            }
        }

        // Default then!
        if ((!(strlen($def) > 0)) || (!file_exists(get_custom_file_base() . '/themes/' . $def))) {
            $def = array_key_exists('default', $map) ? $map['default'] : 'default';
        }

        return $def;
    }

    /**
     * Find if the specified member ID is marked as staff or not.
     *
     * @param  MEMBER $member_id The member ID
     * @return boolean Whether the member is staff
     */
    protected function _is_staff(int $member_id) : bool
    {
        return !empty(array_intersect($this->get_members_groups($member_id), $this->get_moderator_groups()));
    }

    /**
     * Find if the specified member ID is marked as a super admin or not.
     *
     * @param  MEMBER $member_id The member ID
     * @return boolean Whether the member is a super admin
     */
    protected function _is_super_admin(int $member_id) : bool
    {
        return !empty(array_intersect($this->get_members_groups($member_id), $this->get_super_admin_groups()));
    }

    /**
     * Get the number of members currently online on the forums.
     *
     * @return integer The number of members
     */
    public function get_num_users_forums() : int
    {
        return $this->db->query_value_if_there('SELECT COUNT(*) FROM ' . $this->db->get_table_prefix() . 'sessions WHERE session_time>' . strval(time() - 60 * intval(get_option('users_online_time'))));
    }

    /**
     * Get the number of members registered on the forum.
     *
     * @return integer The number of members
     */
    public function get_num_members() : int
    {
        return $this->db->query_select_value('users', 'COUNT(*)') - 1; // IDEA: #5063 on the tracker
    }

    /**
     * Get the total topics ever made on the forum.
     *
     * @return integer The number of topics
     */
    public function get_num_topics() : int
    {
        static $num_topics = null;
        if ($num_topics === null) {
            $num_topics = $this->db->query_select_value('topics', 'COUNT(*)');
        }
        return $num_topics;
    }

    /**
     * Get the total posts ever made on the forum.
     *
     * @return integer The number of posts
     */
    public function get_num_forum_posts() : int
    {
        static $num_posts = null;
        if ($num_posts === null) {
            $num_posts = $this->db->query_select_value('posts', 'COUNT(*)');
        }
        return $num_posts;
    }

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

    /**
     * Get a member ID from the given member's username.
     *
     * @param  SHORT_TEXT $username The username
     * @return ?MEMBER The member ID (null: not found)
     */
    public function get_member_from_username(string $username) : ?int
    {
        if ($username == do_lang('GUEST')) {
            return $this->get_guest_id();
        }

        return $this->db->query_select_value_if_there('users', 'user_id', ['username_clean' => cms_mb_strtolower($username)]);
    }

    /**
     * Get a member ID from the given member's e-mail address.
     *
     * @param  SHORT_TEXT $email_address The member e-mail address
     * @return ?MEMBER The member ID (null: not found)
     */
    public function get_member_from_email_address(string $email_address) : ?int
    {
        $results = $this->db->query_select('users', ['user_id', 'user_regdate'], ['user_email' => $email_address], 'ORDER BY user_regdate DESC', 1);
        return array_key_exists(0, $results) ? $results[0]['user_id'] : null;
    }

    /**
     * Get the IDs of the admin usergroups.
     *
     * @return array The admin usergroup IDs
     */
    protected function _get_super_admin_groups() : array
    {
        $admin_group = $this->db->query_select_value_if_there('groups', 'group_id', ['group_name' => 'ADMINISTRATORS'], 'ORDER BY group_id DESC');
        if ($admin_group === null) {
            return [];
        }
        return [$admin_group];
    }

    /**
     * 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() : array
    {
        $moderator_group = $this->db->query_value_if_there('SELECT group_id FROM ' . $this->db->get_table_prefix() . 'groups WHERE ' . db_string_equal_to('group_name', 'GLOBAL_MODERATORS'));
        if ($moderator_group === null) {
            return [];
        }
        return [$moderator_group];
    }

    /**
     * Get the forum usergroup list.
     *
     * @return array The usergroup list
     */
    protected function _get_usergroup_list() : array
    {
        $results = $this->db->query('SELECT group_id,group_name FROM ' . $this->db->get_table_prefix() . 'groups');
        $results2 = collapse_2d_complexity('group_id', 'group_name', $results);
        return $results2;
    }

    /**
     * Get the forum usergroup relating to the specified member ID.
     *
     * @param  MEMBER $member_id The member ID
     * @return array The array of forum usergroups
     */
    protected function _get_members_groups(int $member_id) : array
    {
        //if ($member_id == $this->get_guest_id()) return [1]; May not hold true

        $groups = collapse_1d_complexity('group_id', $this->db->query_select('user_group', ['group_id'], ['user_pending' => 0, 'user_id' => $member_id]));
        $groups[] = $this->get_member_row_field($member_id, 'group_id');
        if (count($groups) <= 1) {
            $all_groups = $this->get_usergroup_list();
            if ((empty($groups)) || (!array_key_exists($groups[0], $all_groups))) {
                $groups[] = 1;
            }
        }
        return $groups;
    }

    /**
     * Create a member login cookie.
     *
     * @param  MEMBER $member_id The member ID
     * @param  ?SHORT_TEXT $username The username (null: lookup)
     * @param  string $password_raw The password (note this is sometimes not used by forum drivers, as they can also do something with what is already in the database instead)
     */
    public function create_login_cookie(int $member_id, ?string $username, string $password_raw)
    {
        $member_cookie_name = get_member_cookie();
        $pass_cookie_name = get_pass_cookie();
        $session_cookie_name = preg_replace('#_u$#', '_sid', $member_cookie_name);

        require_code('crypt');

        $hash = get_secure_random_string(17);
        $this->db->query_insert('sessions_keys', ['key_id' => md5($hash), 'user_id' => $member_id, 'last_ip' => strval(ip2long(get_ip_address())), 'last_login' => time()]);

        if (substr($member_cookie_name, 0, 5) != 'cms__') {
            $session_id = get_secure_random_string();
            $this->db->query_insert('sessions', [
                'session_id' => $session_id,
                'session_user_id' => $member_id,
                'session_forum_id' => 0,
                'session_last_visit' => time(),
                'session_start' => time(),
                'session_time' => time(),
                'session_ip' => get_ip_address(),
                'session_browser' => get_browser_string(),
                'session_forwarded_for' => '',
                'session_page' => '',
                'session_viewonline' => 1,
                'session_autologin' => 1,
                'session_admin' => $this->_is_super_admin($member_id) ? 1 : 0,
            ]);
        } else {
            $session_id = null;
        }

        $cookie = serialize([$member_cookie_name => strval($member_id), $pass_cookie_name => $hash, $session_cookie_name => $session_id]);

        cms_setcookie($member_cookie_name, strval($member_id), false, true);
        cms_setcookie($pass_cookie_name, $hash, false, true);
        if ($session_id !== null) {
            cms_setcookie($session_cookie_name, $session_id, false, true);
        }
    }

    /**
     * Try and log in using a member cookie.
     * Should only be called if the cookie exists.
     *
     * @return ?array A map of 'id' and 'error'. If 'id' is null, an error occurred and 'error' is set (null: no cookie)
     */
    public function authorise_cookie_login() : ?array
    {
        $member_cookie_name = get_member_cookie();
        $pass_cookie_name = get_pass_cookie();

        if ((!isset($_COOKIE[$member_cookie_name])) || (!isset($_COOKIE[$pass_cookie_name]))) {
            return null;
        }

        $member_id = intval($_COOKIE[get_member_cookie()]);
        $password_hashed = $_COOKIE[get_pass_cookie()];

        return $this->_authorise_login(null, $member_id, $password_hashed, true);
    }

    /**
     * Delete the login cookie, if it exists.
     */
    public function eat_login_cookie()
    {
        $member_cookie_name = get_member_cookie();
        $pass_cookie_name = get_pass_cookie();
        $session_cookie_name = preg_replace('#_u$#', '_sid', $member_cookie_name);

        cms_eatcookie($member_cookie_name);
        unset($_COOKIE[$member_cookie_name]);
        cms_eatcookie($pass_cookie_name);
        unset($_COOKIE[$pass_cookie_name]);
        cms_eatcookie($session_cookie_name);
        unset($_COOKIE[$session_cookie_name]);
    }

    /**
     * Find if the given member ID and password is valid. If username is null, then the member ID is used instead.
     *
     * @param  ?SHORT_TEXT $username The member username (null: use $member_id)
     * @param  ?MEMBER $member_id The member ID (null: use $username)
     * @param  string $password_raw The raw password
     * @return array A map of 'id' and 'error'. If 'id' is null, an error occurred and 'error' is set
     */
    public function authorise_login(?string $username, ?int $member_id, string $password_raw) : array
    {
        return $this->_authorise_login($username, $member_id, $password_raw, false);
    }

    /**
     * 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.
     *
     * @param  ?SHORT_TEXT $username The member username (null: use $member_id)
     * @param  ?MEMBER $member_id The member ID (null: use $username)
     * @param  string $password_mixed If $cookie_login is true then this is the value of the password cookie, otherwise it's the password the user tried to log in with
     * @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
     */
    protected function _authorise_login(?string $username, ?int $member_id, string $password_mixed, bool $cookie_login = false) : array
    {
        $out = [];
        $out['id'] = null;

        if ($member_id === null) {
            $rows = $this->db->query_select('users', ['*'], ['username_clean' => cms_mb_strtolower($username)], '', 1);
            if (array_key_exists(0, $rows)) {
                $this->MEMBER_ROWS_CACHED[$rows[0]['user_id']] = $rows[0];
            }
        } else {
            $rows = [];
            $rows[0] = $this->get_member_row($member_id);
        }

        if (!array_key_exists(0, $rows) || $rows[0] === null) { // All hands to lifeboats
            $out['error'] = do_lang_tempcode((get_option('login_error_secrecy') == '1') ? 'MEMBER_INVALID_LOGIN' : '_MEMBER_NO_EXIST', $username);
            return $out;
        }
        $row = $rows[0];
        if ($this->is_banned($row['user_id'])) { // All hands to the guns
            $out['error'] = do_lang_tempcode('YOU_ARE_BANNED');
            return $out;
        }
        if ($cookie_login) {
            $lookup = $this->db->query_select_value_if_there('sessions_keys', 'user_id', ['key_id' => md5($password_mixed)]);
            if ($row['user_id'] !== $lookup) {
                $out['error'] = do_lang_tempcode((get_option('login_error_secrecy') == '1') ? 'MEMBER_INVALID_LOGIN' : 'MEMBER_BAD_PASSWORD');
                return $out;
            }
        } else {
            if (_phpbb_uses_php_password_api($row['user_password'])) {
                if (!password_verify($password_mixed, $row['user_password'])) {
                    $out['error'] = do_lang_tempcode((get_option('login_error_secrecy') == '1') ? 'MEMBER_INVALID_LOGIN' : 'MEMBER_BAD_PASSWORD');
                    return $out;
                }
            } else {
                $password_hashed = _phpbb_hash_crypt($password_mixed, $row['user_password']);
                if (!hash_equals($password_hashed, $row['user_password'])) {
                    $out['error'] = do_lang_tempcode((get_option('login_error_secrecy') == '1') ? 'MEMBER_INVALID_LOGIN' : 'MEMBER_BAD_PASSWORD');
                    return $out;
                }
            }
        }

        $pos = strpos(get_member_cookie(), '_data:userid');
        require_code('users_active_actions');
        if ($pos !== false) {
            cms_eatcookie(substr(get_member_cookie(), 0, $pos) . '_sid');
        }

        $out['id'] = $row['user_id'];
        return $out;
    }

    /**
     * Get a first known IP address of the given member.
     *
     * @param  MEMBER $member_id The member ID
     * @return IP The IP address
     */
    public function get_member_ip(int $member_id) : string
    {
        $ip = $this->db->query_select_value_if_there('posts', 'poster_ip', ['poster_id' => $member_id]);
        if ($ip !== null) {
            return $ip;
        }
        return '';
    }

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

        $rows = $this->db->query_select('users', ['*'], ['user_id' => $member_id], '', 1);
        if (!array_key_exists(0, $rows)) {
            return null;
        }
        $this->MEMBER_ROWS_CACHED[$member_id] = $rows[0];
        return $this->MEMBER_ROWS_CACHED[$member_id];
    }

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