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

/**
 * Standard code module initialisation function.
 *
 * @ignore
 */
function init__cns_general()
{
    global $SET_CONTEXT_FORUM;
    $SET_CONTEXT_FORUM = null;
}

/**
 * Get some forum stats.
 *
 * @return array A map of forum stats
 */
function cns_get_forums_stats() : array
{
    $forums_stats = [];

    if (isset($GLOBALS['CNS_DRIVER'])) {
        $forums_stats['num_topics'] = $GLOBALS['CNS_DRIVER']->get_num_topics();
        $forums_stats['num_posts'] = $GLOBALS['CNS_DRIVER']->get_num_forum_posts();
        $forums_stats['num_members'] = $GLOBALS['CNS_DRIVER']->get_num_members();
    } else {
        $forums_stats['num_topics'] = 0;
        $forums_stats['num_posts'] = 0;
        $forums_stats['num_members'] = 0;
    }

    $temp = get_value_newer_than('cns_newest_member_id', time() - 60 * 60 * 1);
    $forums_stats['newest_member_id'] = ($temp === null) ? null : intval($temp);
    if ($forums_stats['newest_member_id'] !== null) {
        $forums_stats['newest_member_username'] = get_value_newer_than('cns_newest_member_username', time() - 60 * 60 * 1);
    } else {
        $forums_stats['newest_member_username'] = null;
    }
    if ($forums_stats['newest_member_username'] === null) {
        $sql = 'SELECT m_username,id,m_join_time FROM ' . $GLOBALS['FORUM_DB']->get_table_prefix() . 'f_members WHERE ';
        $sql .= db_string_equal_to('m_validated_email_confirm_code', '') . ' AND id<>' . strval($GLOBALS['FORUM_DRIVER']->get_guest_id());
        if (addon_installed('validation')) {
            $sql .= ' AND m_validated=1';
        }
        $sql .= ' ORDER BY m_join_time DESC';
        $newest_member = $GLOBALS['FORUM_DB']->query($sql, 1); // Only ordered by m_join_time and not double ordered with ID to make much faster in MySQL

        if (array_key_exists(0, $newest_member)) {
            $forums_stats['newest_member_id'] = $newest_member[0]['id'];
            $forums_stats['newest_member_username'] = $newest_member[0]['m_username'];
        } else {
            $forums_stats['newest_member_id'] = $GLOBALS['FORUM_DRIVER']->get_guest_id();
            $forums_stats['newest_member_username'] = do_lang('GUEST');
        }
        if (get_db_type() != 'xml') {
            set_value('cns_newest_member_id', strval($forums_stats['newest_member_id']));
            set_value('cns_newest_member_username', $forums_stats['newest_member_username']);
        }
    }

    return $forums_stats;
}

/**
 * Get details on a member profile. This function aggregates a whole lot of data together and also makes some calculations.
 * If something is not set, it's not set in the result map. We don't use blank or null here.
 * See the member_personal_links_and_details function for a non-CNS specific UI retriever.
 *
 * @param  MEMBER $member_id The member to get details of
 * @param  ?array $need Array of details needed to be returned (it will return more than specified, but this helps performance). Actually even requested stuff may not be set if it's not defined. (null: return everything)
 * @param  boolean $include_encrypted_cpfs Whether to include encrypted CPFs
 * @param  ?boolean $cpf_preview_mode Whether to only gather preview CPFs. (null: don't care).
 * @param  ?MEMBER $member_id_viewing The member viewing the details. (null: current member)
 * @return ?array A map of details (null: not found)
 */
function cns_read_in_member_profile(int $member_id, ?array $need = null, bool $include_encrypted_cpfs = false, ?bool $cpf_preview_mode = null, ?int $member_id_viewing = null) : ?array
{
    if ($member_id_viewing === null) {
        $member_id_viewing = get_member();
    }

    $sz = serialize([$member_id, $need, $include_encrypted_cpfs, $cpf_preview_mode, $member_id_viewing]);

    static $cache = [];
    if (isset($cache[$sz])) {
        return $cache[$sz];
    }

    require_lang('cns');
    require_code('users2');
    require_code('cns_members');
    require_code('cns_groups');
    require_code('temporal2');

    $row = $GLOBALS['CNS_DRIVER']->get_member_row($member_id);
    if ($row === null) {
        $cache[$sz] = null;
        return null;
    }

    // Low-cost details
    $last_visit_time = (($member_id == get_member()) && (array_key_exists('last_visit', $_COOKIE))) ? intval($_COOKIE['last_visit']) : $row['m_last_visit_time'];
    $is_banned = $GLOBALS['FORUM_DRIVER']->is_banned($member_id);
    $join_time = $row['m_join_time'];
    $member_info = [
        'username' => $row['m_username'],
        'display_name' => $GLOBALS['FORUM_DRIVER']->get_username($member_id, true),
        'last_visit_time' => $last_visit_time,
        'last_visit_date' => get_timezoned_date_time($last_visit_time),
        'posts' => $GLOBALS['FORUM_DRIVER']->get_post_count($member_id),
        'topics' => $GLOBALS['FORUM_DRIVER']->get_topic_count($member_id),
        'join_time' => $join_time,
        'join_date' => get_timezoned_date_time($join_time),
        'banned' => $is_banned,
        'is_staff' => $GLOBALS['FORUM_DRIVER']->is_staff($member_id),
        'is_super_admin' => $GLOBALS['FORUM_DRIVER']->is_super_admin($member_id),
        'validated' => ($row['m_validated'] == 1),
        'self_validated' => ($row['m_validated_email_confirm_code'] != ''),
        'highlighted_name' => ($row['m_highlighted_name'] == 1),
        'profile_views' => $row['m_profile_views'],
        'total_sessions' => $row['m_total_sessions'],
    ];
    if ($row['m_email_address'] != '') {
        $member_info['email_address'] = $row['m_email_address'];
    }
    if ($row['m_ip_address'] != '') {
        $member_info['ip_address'] = $row['m_ip_address'];
    }
    if ($row['m_theme'] != '') {
        $member_info['theme'] = $row['m_theme'];
    }
    if ($row['m_language'] != '') {
        $member_info['language'] = $row['m_language'];
    }
    if ($row['m_last_submit_time'] !== null) {
        $member_info['last_submit_time'] = $row['m_last_submit_time'];
        $member_info['last_submit_date'] = get_timezoned_date_time($row['m_last_submit_time']);
        $member_info['submit_days_ago'] = intval(floor(floatval(time() - $row['m_last_submit_time']) / 60.0 / 60.0 / 24.0));
    }
    if (($row['m_probation_expiration_time'] !== null) && ($row['m_probation_expiration_time'] > time())) {
        $member_info['on_probation_until'] = $row['m_probation_expiration_time'];
    }

    // Points
    if (($need === null) || (in_array('points', $need)) || (in_array('num_points_advance', $need))) {
        if (addon_installed('points')) {
            require_code('points');
            $num_points = points_rank($member_id);
            $member_info['points'] = $num_points;
        }
    }

    // Signature details
    if (($need === null) || (in_array('signature', $need)) || (in_array('signature_comcode', $need))) {
        if (addon_installed('cns_signatures')) {
            $member_info['signature_comcode'] = get_translated_text($row['m_signature'], $GLOBALS['FORUM_DB']);

            global $SIGNATURES_CACHE;
            if (array_key_exists($member_id, $SIGNATURES_CACHE)) {
                $member_info['signature'] = $SIGNATURES_CACHE[$member_id];
            } else {
                $just_member_row = db_map_restrict($row, ['id', 'm_signature']);
                $member_info['signature'] = get_translated_tempcode('f_members', $just_member_row, 'm_signature', $GLOBALS['FORUM_DB']);

                if (($is_banned) && (!$GLOBALS['FORUM_DRIVER']->is_super_admin($member_id_viewing))) {
                    $member_info['signature'] = new Tempcode(); // Spammers may use signatures as a way to justify even accounts that will get banned
                }

                $SIGNATURES_CACHE[$member_id] = $member_info['signature'];
            }
        }
    }

    // Last online
    if (($need === null) || (in_array('online', $need)) || (in_array('online_now', $need))) {
        $member_info['online'] = member_is_online($member_id);
        if (member_is_online($member_id)) {
            $online_now = do_lang_tempcode('YES');
            $_online_now = true;
        } else {
            $_online_now = false;
            $minutes_ago = intval(floor((floatval(time() - $last_visit_time) / 60.0)));
            $hours_ago = intval(floor((floatval(time() - $last_visit_time) / 60.0 / 60.0)));
            $days_ago = intval(floor((floatval(time() - $last_visit_time) / 60.0 / 60.0 / 24.0)));
            $months_ago = intval(floor((floatval(time() - $last_visit_time) / 60.0 / 60.0 / 24.0 / 31.0)));
            if ($minutes_ago < 180) {
                $online_now = do_lang_tempcode('_ONLINE_NOW_NO_MINUTES', escape_html(integer_format($minutes_ago)));
            } elseif ($hours_ago < 72) {
                $online_now = do_lang_tempcode('_ONLINE_NOW_NO_HOURS', escape_html(integer_format($hours_ago)));
            } elseif ($days_ago < 93) {
                $online_now = do_lang_tempcode('_ONLINE_NOW_NO_DAYS', escape_html(integer_format($days_ago)));
            } else {
                $online_now = do_lang_tempcode('_ONLINE_NOW_NO_MONTHS', escape_html(integer_format($months_ago)));
            }
        }
        $member_info['online_now'] = $online_now;
        $member_info['_online_now'] = $_online_now;
    }

    // Primary usergroup
    $primary_group = cns_get_member_primary_group($member_id);
    if (($need === null) || (in_array('primary_group', $need)) || (in_array('primary_group_name', $need))) {
        $member_info['primary_group'] = $primary_group;
        $member_info['primary_group_name'] = cns_get_group_name($primary_group);
    }

    // Secondary usergroups
    if (($need === null) || (in_array('secondary_groups', $need)) || (in_array('secondary_groups_named', $need))) {
        $secondary_groups = cns_get_members_groups($member_id, true, false);
        unset($secondary_groups[$primary_group]);
        $all_usergroups = $GLOBALS['FORUM_DRIVER']->get_usergroup_list($member_id != $member_id_viewing, false, false, array_keys($secondary_groups), $member_id);
        $secondary_groups_named = [];
        foreach (array_keys($secondary_groups) as $group_id) {
            if (isset($all_usergroups[$group_id])) {
                $secondary_groups_named[$group_id] = $all_usergroups[$group_id];
            }
        }
        $member_info += [
            'secondary_groups' => array_keys($secondary_groups),
            'secondary_groups_named' => $secondary_groups_named,
        ];
    }

    // Points
    if (addon_installed('points')) {
        if (($need === null) || (in_array('points_used', $need))) {
            $member_info += [
                'points_used' => points_used($member_id),
            ];
        }
        if (($need === null) || (in_array('points_balance', $need))) {
            $member_info += [
                'points_balance' => points_balance($member_id),
            ];
        }
        if (get_option('enable_gift_points') == '1') {
            if (($need === null) || (in_array('gift_points_balance', $need))) {
                $member_info += [
                    'gift_points_balance' => gift_points_balance($member_id),
                ];
            }
            if (($need === null) || (in_array('gift_points_sent', $need))) {
                $member_info += [
                    'gift_points_sent' => gift_points_sent($member_id),
                ];
            }
        }
    }

    // Usergroup subscriptions
    if (($need === null) || (in_array('subscriptions', $need))) {
        if (addon_installed('ecommerce')) {
            require_code('ecommerce_subscriptions');
            $member_info['subscriptions'] = find_member_subscriptions($member_id);
        }
    }

    // Friendships
    if (($need === null) || (in_array('likes', $need)) || (in_array('liked', $need))) {
        if ((addon_installed('chat')) && ($member_id != $member_id_viewing)) {
            require_code('chat');
            $member_info += [
                'likes' => member_befriended($member_id, $member_id_viewing),
                'liked' => member_befriended($member_id_viewing, $member_id),
            ];
        }
    }

    // Times
    if (($need === null) || (in_array('timezone_raw', $need)) || (in_array('timezone', $need)) || (in_array('time_for_them_raw', $need)) || (in_array('time_for_them', $need))) {
        $users_timezone = get_users_timezone($member_id);
        $member_info += [
            'timezone_raw' => $users_timezone,
            'timezone' => make_nice_timezone_name($users_timezone),
            'time_for_them_raw' => tz_time(time(), $users_timezone),
            'time_for_them' => get_timezoned_time(time(), false, false, $member_id),
        ];
    }

    // Since last visit
    if (($need === null) || (in_array('new_posts', $need)) || (in_array('new_topics', $need))) {
        $new_posts = $GLOBALS['FORUM_DB']->query_value_if_there('SELECT COUNT(*) AS mycnt FROM ' . $GLOBALS['FORUM_DB']->get_table_prefix() . 'f_posts WHERE p_cache_forum_id IS NOT NULL AND p_time>' . strval($last_visit_time));
        $new_topics = $GLOBALS['FORUM_DB']->query_value_if_there('SELECT COUNT(*) AS mycnt FROM ' . $GLOBALS['FORUM_DB']->get_table_prefix() . 'f_topics WHERE t_forum_id IS NOT NULL AND t_cache_first_time>' . strval($last_visit_time));
        $member_info += [
            'new_posts' => $new_posts,
            'new_topics' => $new_topics,
        ];
    }

    // Browser
    if ((addon_installed('stats')) && (($need === null) || (in_array('browser', $need)) || (in_array('operating_system', $need)))) {
        $last_stats = $GLOBALS['SITE_DB']->query_select('stats', ['browser', 'operating_system', 'date_and_time'], ['member_id' => $member_id], 'ORDER BY date_and_time DESC', 1);
        if (array_key_exists(0, $last_stats)) {
            $member_info['browser'] = $last_stats[0]['browser'];
            $member_info['operating_system'] = $last_stats[0]['operating_system'];
        }
    }

    // Last viewed page
    if (($need === null) || (in_array('current_action', $need))) {
        $_at_title = $GLOBALS['SITE_DB']->query_select('sessions', ['the_title', 'last_activity'], ['member_id' => $member_id], 'ORDER BY last_activity DESC', 1);
        if (array_key_exists(0, $_at_title)) {
            $member_info['current_action'] = $_at_title[0]['the_title'];
        }
    }

    // Custom fields
    if (($need === null) || (in_array('custom_fields', $need))) {
        $member_info['custom_fields'] = cns_get_all_custom_fields_match_member(
            $member_id, // member
            (($member_id_viewing != $member_id) && (!has_privilege($member_id_viewing, 'view_any_profile_field'))) ? 1 : null, // public view
            (($member_id_viewing != $member_id) && (!has_privilege($member_id_viewing, 'view_any_profile_field'))) ? 1 : null, // owner view
            null, // owner set
            $include_encrypted_cpfs ? null : 0, // encrypted
            null, // required
            ($cpf_preview_mode !== true) ? null : 1, // show in posts
            ($cpf_preview_mode !== false) ? null : 1 // show in post previews
        );

        // Remove internal use only fields
        require_code('cns_members_action2');
        $fields_to_skip = _cpfs_internal_use_only();
        foreach ($member_info['custom_fields'] as $key => $field) {
            if (in_array(@intval($field['FIELD_ID']), $fields_to_skip)) {
                unset($member_info['custom_fields'][$key]);
            }
        }
    }

    // Custom data
    if (($need === null) || (in_array('custom_data', $need))) {
        static $hook_objects = null;
        if ($hook_objects === null) {
            $hook_objects = find_all_hook_obs('systems', 'member_boxes', 'Hook_member_boxes_');
        }
        $member_info['custom_data'] = [];
        foreach ($hook_objects as $hook_object) {
            $_temp = $hook_object->run($member_id);
            if (is_array($_temp)) {
                $member_info['custom_data'] += $_temp;
            }
        }
    }

    // Birthday
    $dob_test = get_member_dob_details($member_id);
    if ($dob_test !== null) {
        $member_info += [
            'dob_label' => $dob_test[0],
            'dob' => $dob_test[1],
            '_dob_censored' => $dob_test[2],
            '_dob' => $dob_test[3],
            'age' => $dob_test[4],
        ];
    }

    // Find title
    $title = get_member_title($member_id);
    if ($title != '') {
        $member_info['poster_title'] = $title;
    }

    // Any warnings?
    if (($need === null) || (in_array('warnings', $need))) {
        if ((has_privilege($member_id_viewing, 'see_warnings')) && (addon_installed('cns_warnings'))) {
            require_code('cns_moderation');
            $member_info['warnings'] = cns_get_warnings($member_id);
        }
    }
    $member_info['num_warnings'] = $row['m_cache_warnings'];

    // Gallery counts
    if (($need === null) || (in_array('galleries', $need))) {
        if ((addon_installed('galleries')) && (get_option('show_gallery_counts') == '1')) {
            $gallery_cnt = $GLOBALS['SITE_DB']->query_value_if_there('SELECT COUNT(*) AS cnt FROM ' . $GLOBALS['SITE_DB']->get_table_prefix() . 'galleries WHERE name LIKE \'' . db_encode_like('member_' . strval($member_id) . '_%') . '\'');

            if ($gallery_cnt > 1) {
                require_lang('galleries');
                $member_info['galleries'] = $gallery_cnt;
            }
        }
    }

    // Find forum with most posts
    if (($need === null) || (in_array('most_active_forum', $need))) {
        $forums = $GLOBALS['FORUM_DB']->query('SELECT id,f_name FROM ' . $GLOBALS['FORUM_DB']->get_table_prefix() . 'f_forums WHERE f_cache_num_posts>0');
        $best_yet_forum = 0; // Initialise to integer type
        $best_yet_forum = null;
        $_most_active_forum = null;
        $_best_yet_forum = $GLOBALS['FORUM_DB']->query_select('f_posts', ['COUNT(*) as cnt', 'p_cache_forum_id'], ['p_posting_member' => $member_id], 'GROUP BY p_cache_forum_id ORDER BY cnt DESC', 1); // order by and limit have been added since original code, makes it run a bit faster
        $_best_yet_forum = collapse_2d_complexity('p_cache_forum_id', 'cnt', $_best_yet_forum);
        foreach ($forums as $forum) {
            if (((array_key_exists($forum['id'], $_best_yet_forum)) && (($best_yet_forum === null) || ($_best_yet_forum[$forum['id']] > $best_yet_forum)))) {
                $_most_active_forum = has_category_access($member_id_viewing, 'forums', strval($forum['id'])) ? protect_from_escaping(escape_html($forum['f_name'])) : do_lang_tempcode('PROTECTED_FORUM');
                $best_yet_forum = $_best_yet_forum[$forum['id']];
            }
        }
        $post_count = $GLOBALS['FORUM_DRIVER']->get_member_row_field($member_id, 'm_cache_num_posts');
        $best_post_fraction = ($post_count == 0) ? do_lang_tempcode('NA_EM') : make_string_tempcode(integer_format(100 * @intval(round(floatval($best_yet_forum) / floatval($post_count)))));
        $member_info['most_active_forum'] = ($_most_active_forum === null) ? new Tempcode() : do_lang_tempcode('_MOST_ACTIVE_FORUM', $_most_active_forum, make_string_tempcode(integer_format($best_yet_forum)), [$best_post_fraction]);
    }

    // Further posting details
    if (($need === null) || (in_array('posts_details', $need))) {
        $days_joined = intval(round((time() - $join_time) / 60 / 60 / 24));
        $total_posts = $GLOBALS['FORUM_DRIVER']->get_num_forum_posts();
        $member_info['posts_details'] = do_lang_tempcode('_COUNT_POSTS', escape_html(integer_format($post_count, 0)), escape_html(float_format(floatval($post_count) / floatval(($days_joined == 0) ? 1 : $days_joined))), [escape_html(float_format(floatval(100 * $post_count) / floatval(($total_posts == 0) ? 1 : $total_posts)))]);
    }

    // Find photo
    if (($need === null) || (in_array('photo', $need))) {
        $photo = $GLOBALS['CNS_DRIVER']->get_member_photo_url($member_id, true);
        if ($photo != '') {
            $member_info['photo'] = $photo;
        }
    }

    // Find avatar
    if (($need === null) || (in_array('avatar', $need))) {
        $avatar = $GLOBALS['CNS_DRIVER']->get_member_avatar_url($member_id);
        if ($avatar != '') {
            $member_info['avatar'] = $avatar;
        }
    }

    // Find how many points we need to advance
    if (($need === null) || (in_array('num_points_advance', $need))) {
        if (addon_installed('points')) {
            $promotion_threshold = cns_get_group_property($primary_group, 'promotion_threshold');
            if ($promotion_threshold !== null) {
                $num_points_advance = $promotion_threshold - $num_points;
                $member_info['num_points_advance'] = $num_points_advance;
            }
        }
    }

    $cache[$sz] = $member_info;

    return $member_info;
}

/**
 * Get details of a member's date of birth / birthday / age.
 *
 * @param  MEMBER $member_id The member to check
 * @return ?array A tuple: label to refer to with, written date string, timestamp with censoring applied if needed, timestamp without censoring, age or null if censored (null: none)
 */
function get_member_dob_details(int $member_id) : ?array
{
    if (is_guest($member_id)) {
        return null;
    }

    $day = $GLOBALS['FORUM_DRIVER']->get_member_row_field($member_id, 'm_dob_day');
    $month = $GLOBALS['FORUM_DRIVER']->get_member_row_field($member_id, 'm_dob_month');
    $year = $GLOBALS['FORUM_DRIVER']->get_member_row_field($member_id, 'm_dob_year');

    if (($day === null) || ($month === null) || ($year === null)) {
        return null;
    }

    require_code('temporal');

    $_dob = cms_mktime(12, 0, 0, $month, $day, $year);
    $_dob_censored = cms_mktime(12, 0, 0, $month, $day);
    if ($GLOBALS['FORUM_DRIVER']->get_member_row_field($member_id, 'm_reveal_age') == 1) {
        $dob = get_timezoned_date($_dob, false, true);
        $_dob_censored_if_needed = $_dob; // No censoring needed
        $label = do_lang('DATE_OF_BIRTH');

        $age = intval(date('Y')) - $year;
        if ($month > intval(date('m'))) {
            $age--;
        }
        if (($month == intval(date('m'))) && ($day > intval(date('j')))) {
            $age--;
        }
    } else {
        $_dob_censored_if_needed = $_dob_censored;
        $dob = cms_date(do_lang('date_no_year'), $_dob_censored);
        $label = do_lang('dates:BIRTHDAY');

        $age = null;
    }

    return [$label, $dob, $_dob_censored_if_needed, $_dob, $age];
}

/**
 * Get a member title.
 *
 * @param  MEMBER $member_id Member ID
 * @return string Member title
 */
function get_member_title(int $member_id) : string
{
    if (!addon_installed('cns_member_titles')) {
        return '';
    }

    $title = addon_installed('cns_member_titles') ? $GLOBALS['FORUM_DRIVER']->get_member_row_field($member_id, 'm_title') : '';
    $primary_group = $GLOBALS['FORUM_DRIVER']->get_member_row_field($member_id, 'm_primary_group');
    if ($title == '') {
        $title = get_translated_text(cns_get_group_property($primary_group, 'title'), $GLOBALS['FORUM_DB']);
    }
    return $title;
}

/**
 * Get a usergroup colour based on its ID number.
 *
 * @param  GROUP $gid ID number
 * @return string Colour
 */
function get_group_colour(int $gid) : string
{
    $all_colours = ['cns-gcol-1', 'cns-gcol-2', 'cns-gcol-3', 'cns-gcol-4', 'cns-gcol-5', 'cns-gcol-6', 'cns-gcol-7', 'cns-gcol-8', 'cns-gcol-9', 'cns-gcol-10', 'cns-gcol-11', 'cns-gcol-12', 'cns-gcol-13', 'cns-gcol-14', 'cns-gcol-15', 'cns-gcol-16', 'cns-gcol-17', 'cns-gcol-18'];
    return $all_colours[$gid % count($all_colours)];
}

/**
 * Find all the birthdays in a certain day.
 *
 * @param  ?TIME $time A timestamps that exists in the certain day (null: now)
 * @param  boolean $ignore_upper_limit Whether we should ignore the configured upper limit
 * @param  ?string $timezone Use the specified timezone when determining who has a birthday (null: use the timezone of the current member)
 * @return array List of maps describing the members whose birthday it is on the certain day
 */
function cns_find_birthdays(?int $time = null, bool $ignore_upper_limit = false, ?string $timezone = null) : array
{
    $upper_limit = intval(get_option('enable_birthdays'));
    if (!$ignore_upper_limit && intval($upper_limit) <= 0) {
        return [];
    }

    if ($time === null) {
        $time = time();
    }

    list($day, $month, $year) = explode(' ', date('j m Y', (($timezone !== null) ? tz_time($time, $timezone) : utctime_to_usertime($time))));
    $rows = $GLOBALS['FORUM_DB']->query_select(
        'f_members',
        ['id', 'm_username', 'm_reveal_age', 'm_dob_year', 'm_last_visit_time'],
        ['m_dob_day' => intval($day), 'm_dob_month' => intval($month)],
        'ORDER BY m_last_visit_time DESC',
        $ignore_upper_limit ? null : ($upper_limit + 1)
    );
    if (!$ignore_upper_limit && count($rows) == $upper_limit + 1) {
        return [];
    }

    $birthdays = [];
    foreach ($rows as $row) {
        if (!has_privilege($row['id'], 'appear_under_birthdays')) {
            continue;
        }

        $birthday = ['id' => $row['id'], 'username' => $row['m_username']];
        if ($row['m_reveal_age'] == 1) {
            $birthday['age'] = intval($year) - $row['m_dob_year'];
        }

        $birthdays[] = $birthday;
    }

    return $birthdays;
}

/**
 * Turn a list of maps describing buttons, into a Tempcode button panel.
 *
 * @param  array $buttons List of maps (each map contains: url, img, title)
 * @return Tempcode The button panel
 */
function cns_button_screen_wrap(array $buttons) : object
{
    if (empty($buttons)) {
        return new Tempcode();
    }

    $b = new Tempcode();
    foreach ($buttons as $button) {
        $b->attach(do_template('BUTTON_SCREEN', ['_GUID' => 'bdd441c40c5b03134ce6541335fece2c', 'REL' => array_key_exists('rel', $button) ? $button['rel'] : null, 'IMMEDIATE' => $button['immediate'], 'URL' => $button['url'], 'IMG' => $button['img'], 'TITLE' => $button['title']]));
    }
    return $b;
}

/**
 * Set the forum context.
 *
 * @param  ?AUTO_LINK $forum_id Forum ID (null: Private Topics)
 */
function cns_set_context_forum(?int $forum_id)
{
    global $SET_CONTEXT_FORUM;
    $SET_CONTEXT_FORUM = $forum_id;
}
