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

/**
 * Edit a forum poll.
 *
 * @param  AUTO_LINK $poll_id The ID of the poll we're editing
 * @param  SHORT_TEXT $question The question
 * @param  BINARY $is_private Whether the result tallies are kept private until the poll is made non-private
 * @param  BINARY $is_open Whether the poll is open for voting
 * @param  integer $minimum_selections The minimum number of selections that may be made
 * @param  integer $maximum_selections The maximum number of selections that may be made
 * @param  BINARY $requires_reply Whether members must have a post in the topic before they made vote
 * @param  array $answers A list of the potential voteable answers
 * @param  BINARY $view_member_votes Whether others can see which members voted for each option on the results
 * @param  BINARY $vote_revocation Whether to allow voters to revoke their vote when the poll's voting is still open
 * @param  BINARY $guests_can_vote Whether guests can vote on the poll without logging in
 * @param  BINARY $point_weighting Whether results for this poll should be weighed based on voter's points
 * @param  LONG_TEXT $reason The reason for editing the poll
 * @param  ?TIME $poll_closing_time The time voting should close on this poll (null: the poll will not close automatically)
 * @param  boolean $erase_votes Whether to erase all the current votes
 * @return AUTO_LINK The ID of the topic the poll is on
 */
function cns_edit_poll(int $poll_id, string $question, int $is_private, int $is_open, int $minimum_selections, int $maximum_selections, int $requires_reply, array $answers, int $view_member_votes, int $vote_revocation, int $guests_can_vote, int $point_weighting, string $reason = '', ?int $poll_closing_time = null, bool $erase_votes = false) : int
{
    require_code('cns_polls');
    require_code('cns_polls_action3');

    $topic_info = $GLOBALS['FORUM_DB']->query_select('f_topics', ['*'], ['t_poll_id' => $poll_id], '', 1);
    if (!cns_may_edit_poll_by($topic_info[0]['t_forum_id'], $topic_info[0]['t_cache_first_member_id'])) {
        access_denied('I_ERROR');
    }
    $topic_id = $topic_info[0]['id'];
    $poll_info = $GLOBALS['FORUM_DB']->query_select('f_polls', ['*'], ['id' => $poll_id], '', 1);

    if ((!has_privilege(get_member(), 'may_unblind_own_poll')) && (!has_privilege(get_member(), 'edit_midrange_content', 'topics', ['forums', $topic_info[0]['t_forum_id']])) && ($is_private == 0) && ($poll_info[0]['po_is_private'] == 1)) {
        access_denied('PRIVILEGE', 'may_unblind_own_poll');
    }

    cns_validate_poll($topic_id, $poll_id, $answers, $is_private, $is_open, $minimum_selections, $maximum_selections, $requires_reply, $poll_closing_time, $view_member_votes, $vote_revocation, $guests_can_vote, $point_weighting);

    $GLOBALS['FORUM_DB']->query_update('f_polls', [
        'po_question' => $question,
        'po_is_private' => $is_private,
        'po_is_open' => $is_open,
        'po_minimum_selections' => $minimum_selections,
        'po_maximum_selections' => $maximum_selections,
        'po_requires_reply' => $requires_reply,
        'po_closing_time' => $poll_closing_time,
        'po_view_member_votes' => $view_member_votes,
        'po_vote_revocation' => $vote_revocation,
        'po_guests_can_vote' => $guests_can_vote,
        'po_point_weighting' => $point_weighting
    ], ['id' => $poll_id], '', 1);

    // If we are erasing votes, we can simply remove all answers / votes and re-populate with the new answers
    if ($erase_votes) {
        $GLOBALS['FORUM_DB']->query_delete('f_poll_votes', ['pv_poll_id' => $poll_id]);
        $GLOBALS['FORUM_DB']->query_update('f_polls', ['po_cache_total_votes' => 0, 'po_cache_voting_power' => 0.0], ['id' => $poll_id], '', 1);
        $GLOBALS['FORUM_DB']->query_delete('f_poll_answers', ['pa_poll_id' => $poll_id]);

        foreach ($answers as $i => $answer) {
            $GLOBALS['FORUM_DB']->query_insert('f_poll_answers', [
                'pa_poll_id' => $poll_id,
                'pa_answer' => $answer,
                'pa_cache_num_votes' => 0, // Since we are erasing votes, this should always be 0
                'pa_order' => $i,
                'pa_cache_voting_power' => 0.0,
            ]);
        }
    } else {
        $current_answers = $GLOBALS['FORUM_DB']->query_select('f_poll_answers', ['*'], ['pa_poll_id' => $poll_id]);
        $total_after = count($answers);

        // Loop through the database of current answers
        foreach ($current_answers as $i => $current_answer) {
            if ($i < $total_after) {
                // Update the text for the answer
                $new_answer = $answers[$i];
                $update = ['pa_answer' => $new_answer, 'pa_order' => $i];
                $GLOBALS['FORUM_DB']->query_update('f_poll_answers', $update, ['id' => $current_answer['id']], '', 1);
            } else {
                // Answer was removed
                $GLOBALS['FORUM_DB']->query_delete('f_poll_answers', ['id' => $current_answer['id']], '', 1);
                $GLOBALS['FORUM_DB']->query_delete('f_poll_votes', ['pv_answer_id' => $current_answer['id']], '', 1);

                // Update cached poll voting power
                $new_voting_power = $poll_info['po_cache_voting_power'] - $current_answer['pa_cache_voting_power'];
                $GLOBALS['FORUM_DB']->query_update('f_polls', ['po_cache_voting_power' => $new_voting_power], ['id' => $poll_info['id']], '', 1);
            }
        }
        $i++;

        // Add new answers into the database if applicable
        for (; $i < $total_after; $i++) {
            $new_answer = $answers[$i];
            $GLOBALS['FORUM_DB']->query_insert('f_poll_answers', [
                'pa_poll_id' => $poll_id,
                'pa_answer' => $new_answer,
                'pa_cache_num_votes' => 0,
                'pa_order' => $i,
                'pa_cache_voting_power' => 0.0,
            ]);
        }
    }

    $name = $GLOBALS['FORUM_DB']->query_select_value('f_polls', 'po_question', ['id' => $poll_id]);
    require_code('cns_general_action2');
    cns_mod_log_it('EDIT_TOPIC_POLL', strval($poll_id), $name, $reason);

    return $topic_id;
}

/**
 * Delete a forum poll.
 *
 * @param  AUTO_LINK $poll_id The ID of the poll we're deleting
 * @param  LONG_TEXT $reason The reason for deleting the poll
 * @param  boolean $check_perms Whether to check permissions
 * @return AUTO_LINK The ID of the topic the poll is on
 */
function cns_delete_poll(int $poll_id, string $reason = '', bool $check_perms = true) : int
{
    require_code('cns_polls');
    require_code('cns_polls_action3');

    $info = $GLOBALS['FORUM_DB']->query_select('f_polls', ['id'], ['id' => $poll_id], '', 1);
    if (!array_key_exists(0, $info)) {
        warn_exit(do_lang_tempcode('MISSING_RESOURCE'));
    }

    $topic_info = $GLOBALS['FORUM_DB']->query_select('f_topics', ['*'], ['t_poll_id' => $poll_id], '', 1);
    if (!array_key_exists(0, $topic_info)) {
        warn_exit(do_lang_tempcode('MISSING_RESOURCE', 'topic'));
    }
    if ($check_perms) {
        if (!cns_may_delete_poll_by($topic_info[0]['t_forum_id'], $topic_info[0]['t_cache_first_member_id'])) {
            access_denied('I_ERROR');
        }
    }
    $topic_id = $topic_info[0]['id'];

    $GLOBALS['FORUM_DB']->query_update('f_topics', ['t_poll_id' => null], ['t_poll_id' => $poll_id], '', 1);

    $name = $GLOBALS['FORUM_DB']->query_select_value('f_polls', 'po_question', ['id' => $poll_id]);

    $GLOBALS['FORUM_DB']->query_delete('f_polls', ['id' => $poll_id], '', 1);
    $GLOBALS['FORUM_DB']->query_delete('f_poll_answers', ['pa_poll_id' => $poll_id]);
    $GLOBALS['FORUM_DB']->query_delete('f_poll_votes', ['pv_poll_id' => $poll_id]);

    // Also reverse all points for this poll
    if (addon_installed('points')) {
        require_code('points2');
        points_transactions_reverse_all(true, null, null, 'topic_poll', '', strval($poll_id));
    }

    require_code('cns_general_action2');
    cns_mod_log_it('DELETE_TOPIC_POLL', strval($topic_id) . ':' . strval($poll_id), $name, $reason);

    return $topic_id;
}

/**
 * Place a vote on a specified poll.
 *
 * @param  AUTO_LINK $poll_id The ID of the poll we're voting in
 * @param  array $votes A list of poll answers that are being voted for
 * @param  ?MEMBER $member_id The member that's voting (null: current member)
 * @param  ?array $topic_info The row of the topic the poll is for (null: get it from the DB)
 */
function cns_vote_in_poll(int $poll_id, array $votes, ?int $member_id = null, ?array $topic_info = null)
{
    require_code('cns_polls');
    if (addon_installed('points')) {
        require_code('points');
    }

    // Who's voting
    if ($member_id === null) {
        $member_id = get_member();
    }

    // Check they're allowed to vote
    if (!has_privilege($member_id, 'vote_in_polls')) {
        warn_exit(do_lang_tempcode('VOTE_DENIED'));
    }
    if ($topic_info === null) {
        $topic_info = $GLOBALS['FORUM_DB']->query_select('f_topics', ['id', 't_forum_id', 't_cache_first_title'], ['t_poll_id' => $poll_id], '', 1);
    }
    if (!array_key_exists(0, $topic_info)) {
        warn_exit(do_lang_tempcode('MISSING_RESOURCE'));
    }
    $topic_id = $topic_info[0]['id'];
    $topic_title = $topic_info[0]['t_cache_first_title'];
    $forum_id = $topic_info[0]['t_forum_id'];
    if ((!has_category_access($member_id, 'forums', strval($forum_id))) && ($forum_id !== null)) {
        warn_exit(do_lang_tempcode('VOTE_CHEAT'));
    }
    if (is_guest($member_id)) {
        $voted_already_map = ['pv_poll_id' => $poll_id, 'pv_ip_address' => get_ip_address(), 'pv_member_id' => $GLOBALS['FORUM_DRIVER']->get_guest_id(), 'pv_revoked' => 0];
    } else {
        $voted_already_map = ['pv_poll_id' => $poll_id, 'pv_member_id' => $member_id, 'pv_revoked' => 0];
    }
    $voted_already = $GLOBALS['FORUM_DB']->query_select_value_if_there('f_poll_votes', 'pv_member_id', $voted_already_map);
    if ($voted_already !== null) {
        warn_exit(do_lang_tempcode('ALREADY_VOTED'));
    }

    $rows = $GLOBALS['FORUM_DB']->query_select('f_polls', ['po_is_open', 'po_minimum_selections', 'po_maximum_selections', 'po_requires_reply', 'po_question', 'po_is_private', 'po_closing_time', 'po_view_member_votes', 'po_guests_can_vote'], ['id' => $poll_id], '', 1);
    if (!array_key_exists(0, $rows)) {
        warn_exit(do_lang_tempcode('MISSING_RESOURCE'));
    }

    // Check their vote is valid
    $poll_info = [
        'is_open' => $rows[0]['po_is_open'],
        'closing_time' => $rows[0]['po_closing_time']
    ];
    if ((count($votes) < $rows[0]['po_minimum_selections']) || (count($votes) > $rows[0]['po_maximum_selections']) || !cns_is_poll_open($poll_info)) {
        warn_exit(do_lang_tempcode('VOTE_CHEAT'));
    }
    $answers = collapse_2d_complexity('id', 'pa_answer', $GLOBALS['FORUM_DB']->query_select('f_poll_answers', ['id', 'pa_answer'], ['pa_poll_id' => $poll_id]));
    if (($rows[0]['po_requires_reply'] == 1) && (!cns_has_replied_topic($topic_id, $member_id))) {
        warn_exit(do_lang_tempcode('POLL_REQUIRES_REPLY'));
    }

    // Redirect to login if guests cannot vote
    if ($rows[0]['po_guests_can_vote'] != 1 && is_guest($member_id)) {
        access_denied('NOT_AS_GUEST');
    }

    $points = 0;
    $voting_power = 1.0; // If points addon is not installed, then everyone has a voting power of 1.
    if (addon_installed('points')) {
        $points = points_lifetime($member_id);
        $voting_power = cns_points_to_voting_power($points);
    }

    // Insert votes
    $answer = '';
    $total_voting_power_adjust = 0.0;
    foreach ($votes as $vote) {
        if (!array_key_exists($vote, $answers)) {
            warn_exit(do_lang_tempcode('VOTE_CHEAT'));
        }

        $total_voting_power_adjust += $voting_power;

        $GLOBALS['FORUM_DB']->query_insert('f_poll_votes', [
            'pv_poll_id' => $poll_id,
            'pv_member_id' => $member_id,
            'pv_answer_id' => $vote,
            'pv_ip_address' => get_ip_address(),
            'pv_revoked' => 0,
            'pv_date_time' => time(),
            'pv_points_when_voted' => $points,
            'pv_cache_voting_power' => $voting_power,
        ]);

        // Update cache
        $GLOBALS['FORUM_DB']->query('UPDATE ' . $GLOBALS['FORUM_DB']->get_table_prefix() . 'f_poll_answers SET pa_cache_num_votes=(pa_cache_num_votes+1), pa_cache_voting_power=(pa_cache_voting_power+' . float_to_raw_string($voting_power, 10) . ') WHERE id=' . strval($vote), 1);

        if ($answer != '') {
            $answer .= ', ';
        }
        $answer .= $answers[$vote];
    }

    // Update cache
    $GLOBALS['FORUM_DB']->query('UPDATE ' . $GLOBALS['FORUM_DB']->get_table_prefix() . 'f_polls SET po_cache_total_votes=(po_cache_total_votes+' . strval(count($votes)) . '), po_cache_voting_power=(po_cache_voting_power+' . float_to_raw_string($total_voting_power_adjust, 10) . ') WHERE id=' . strval($poll_id), 1);

    // Award points
    if (addon_installed('points') && addon_installed('cns_forum')) {
        $points_voting_cns = intval(get_option('points_voting_cns'));
        if ($points_voting_cns > 0) {
            require_code('points2');
            require_lang('points');
            points_credit_member($member_id, do_lang('ACTIVITY_CNS_VOTING', $topic_title), $points_voting_cns, 0, null, 0, 'topic_poll', 'vote', strval($poll_id));
        }
    }

    // Send notification to topic subscribers if the poll is not private and is set to reveal which members voted for each option (otherwise this can be too revealing as members can predict the outcome of the poll)
    if ($rows[0]['po_is_private'] == 0 && $rows[0]['po_view_member_votes'] == 1) {
        $poll_title = $rows[0]['po_question'];
        $topic_url = $GLOBALS['FORUM_DRIVER']->topic_url($topic_id, '');
        $username = $GLOBALS['FORUM_DRIVER']->get_username($member_id);
        $subject = do_lang('POLL_VOTE_MAIL_SUBJECT', $username, $answer, [$poll_title, $topic_title, $topic_url], get_lang($member_id));
        $mail = do_lang('POLL_VOTE_MAIL_BODY', $username, $answer, [$poll_title, $topic_title, $topic_url], get_lang($member_id));
        require_code('notifications');
        dispatch_notification('cns_topic', strval($topic_id), $subject, $mail);
    }
}

/**
 * Check if a poll is open by looking at the is_open property and checking if the closing_time has passed.
 *
 * @param  array $poll_info The cns_poll_get_results map of the poll
 * @return boolean Whether the poll is open and voting is allowed
 */
function cns_is_poll_open(array $poll_info) : bool
{
    // Is the poll explicitly marked not open?
    if (!$poll_info['is_open']) {
        return false;
    }

    // Did the poll expire?
    $current_time = time();
    if ($poll_info['closing_time'] !== null && $poll_info['closing_time'] < $current_time) {
        return false;
    }

    return true;
}

/**
 * Revoke the vote by a member in a topic poll.
 *
 * @param  array $topic_info The topic info from cns_read_in_topic for the topic containing the poll
 * @param  ?MEMBER $member_id The ID of the member from which to revoke the vote (null: the current member)
 */
function cns_revoke_vote_in_poll(array $topic_info, ?int $member_id = null)
{
    // Ignore if there is no poll on this topic
    if (!array_key_exists('poll', $topic_info)) {
        return;
    }

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

    // Guests may not revoke their vote
    if (is_guest($member_id)) {
        access_denied('I_ERROR');
    }

    $poll = $topic_info['poll'];
    $poll_info = cns_poll_get_results($poll['id'], false, [0]);

    // Disallow / ignore if voting revocation is disabled or the poll is closed.
    if (!$poll_info['vote_revocation'] || !cns_is_poll_open($poll_info)) {
        access_denied('I_ERROR');
    }

    require_code('cns_general_action2');

    $_answers = $poll_info['answers'];
    $_votes = $poll_info['votes'];

    // Find which answers were submitted on the vote (in case of multi-selections)
    $answers = [];
    foreach ($_votes as $vote) {
        if ($vote['pv_member_id'] != $member_id) {
            continue;
        }

        $_cell = array_search($vote['pv_answer_id'], array_column($_answers, 'id'));
        if ($_cell !== null) {
            $answers[] = $_answers[$_cell]['answer'];
        }
    }

    // Revoke the votes in the database
    if (is_guest($member_id)) {
        $map = ['pv_poll_id' => $poll_info['id'], 'pv_ip_address' => get_ip_address(), 'pv_member_id' => $GLOBALS['FORUM_DRIVER']->get_guest_id()];
    } else {
        $map = ['pv_poll_id' => $poll_info['id'], 'pv_member_id' => $member_id];
    }
    $to_revoke = $GLOBALS['FORUM_DB']->query_select('f_poll_votes', ['pv_answer_id', 'pv_cache_voting_power'], $map);
    $GLOBALS['FORUM_DB']->query_update('f_poll_votes', ['pv_revoked' => 1], $map);

    // Calculate how much voting power was removed
    $total_voting_power_removed = 0.0;
    $answer_voting_power_removed = [];
    $clear_cache_voting_power = false;
    foreach ($to_revoke as $revoked) {
        $voting_power = $revoked['pv_cache_voting_power'];
        if ($voting_power === null) {
            $clear_cache_voting_power = true;
            continue;
        }

        $total_voting_power_removed += $voting_power;
        if (!array_key_exists($revoked['pv_answer_id'], $answer_voting_power_removed)) {
            $answer_voting_power_removed[$revoked['pv_answer_id']] = 0.0;
        }
        $answer_voting_power_removed[$revoked['pv_answer_id']] += $voting_power;
    }

    // Update the poll cache
    if ($clear_cache_voting_power) {
        $GLOBALS['FORUM_DB']->query('UPDATE ' . $GLOBALS['FORUM_DB']->get_table_prefix() . 'f_polls SET po_cache_total_votes=(po_cache_total_votes-' . strval(count($to_revoke)) . '), po_cache_voting_power=NULL WHERE id=' . strval($poll_info['id']), 1);
    } else {
        $GLOBALS['FORUM_DB']->query('UPDATE ' . $GLOBALS['FORUM_DB']->get_table_prefix() . 'f_polls SET po_cache_total_votes=(po_cache_total_votes-' . strval(count($to_revoke)) . '), po_cache_voting_power=(po_cache_voting_power-' . float_to_raw_string($total_voting_power_removed, 10) . ') WHERE id=' . strval($poll_info['id']), 1);
    }
    // Update the poll answers caches
    $poll_answers = $GLOBALS['FORUM_DB']->query_select('f_poll_answers', ['id'], ['pa_poll_id' => $poll_info['id']]);
    foreach ($poll_answers as $answer) {
        if (array_key_exists($answer['id'], $answer_voting_power_removed)) {
            if ($clear_cache_voting_power) {
                $GLOBALS['FORUM_DB']->query('UPDATE ' . $GLOBALS['FORUM_DB']->get_table_prefix() . 'f_poll_answers SET pa_cache_num_votes=(pa_cache_num_votes-1), pa_cache_voting_power=NULL WHERE id=' . strval($answer['id']), 1);
            } else {
                $GLOBALS['FORUM_DB']->query('UPDATE ' . $GLOBALS['FORUM_DB']->get_table_prefix() . 'f_poll_answers SET pa_cache_num_votes=(pa_cache_num_votes-1), pa_cache_voting_power=(pa_cache_voting_power-' . float_to_raw_string($answer_voting_power_removed[$answer['id']], 10) . ') WHERE id=' . strval($answer['id']), 1);
            }
        }
    }

    // Reverse points
    if (addon_installed('points')) {
        require_code('points2');
        points_transactions_reverse_all(null, null, $member_id, 'topic_poll', 'vote', strval($poll['id']));
    }

    // Log the revocation
    cns_mod_log_it('VOTE_REVOCATION', strval($topic_info['id']), implode(', ', $answers));
}

/**
 * Calculate how much voting power a particular poll has in total, and cache it (this also does the same for all answers and votes).
 *
 * @param  AUTO_LINK $poll_id The poll ID
 * @param  boolean $recalculate Recalculate even if a cached voting power already exists
 * @param  ?array $row The database row for the poll if it was already queried elsewhere (null: query for it)
 * @return float The total voting power for the specified poll
 */
function cns_calculate_poll_voting_power(int $poll_id, bool $recalculate = false, ?array $row = null) : float
{
    if ($row === null) {
        $_row = $GLOBALS['FORUM_DB']->query_select('f_polls', ['id', 'po_cache_voting_power'], ['id' => $poll_id], '', 1);
        if ($_row === null || !array_key_exists(0, $_row)) {
            warn_exit(do_lang_tempcode('MISSING_RESOURCE'));
        }
        $row = $_row[0];
    }

    if ($row['po_cache_voting_power'] !== null && !$recalculate) {
        return $row['po_cache_voting_power'];
    }

    $voting_power = 0.0;

    $answers = $GLOBALS['FORUM_DB']->query_select('f_poll_answers', ['id', 'pa_cache_voting_power'], ['pa_poll_id' => $poll_id], '');
    foreach ($answers as $answer) {
        $voting_power += cns_calculate_answer_voting_power($answer['id'], $recalculate, $answer);
    }

    $GLOBALS['FORUM_DB']->query_update('f_polls', ['po_cache_voting_power' => $voting_power], ['id' => $poll_id]);

    return $voting_power;
}

/**
 * Calculate how much voting power a particular poll answer has in total, and cache it (this also does the same for all votes).
 *
 * @param  AUTO_LINK $answer_id The poll answer ID
 * @param  boolean $recalculate Recalculate even if a cached voting power already exists
 * @param  ?array $row The database row for the poll answer if it was already queried elsewhere (null: query for it)
 * @return float The total voting power for the specified poll answer
 */
function cns_calculate_answer_voting_power(int $answer_id, bool $recalculate = false, ?array $row = null) : float
{
    if ($row === null) {
        $_row = $GLOBALS['FORUM_DB']->query_select('f_poll_answers', ['id', 'pa_cache_voting_power'], ['id' => $answer_id], '', 1);
        if ($_row === null || !array_key_exists(0, $_row)) {
            warn_exit(do_lang_tempcode('MISSING_RESOURCE'));
        }
        $row = $_row[0];
    }

    if ($row['pa_cache_voting_power'] !== null && !$recalculate) {
        return $row['pa_cache_voting_power'];
    }

    $voting_power = 0.0;

    $votes = $GLOBALS['FORUM_DB']->query_select('f_poll_votes', ['id', 'pv_points_when_voted', 'pv_cache_voting_power', 'pv_revoked'], ['pv_answer_id' => $answer_id, 'pv_revoked' => 0], '');
    foreach ($votes as $vote) {
        $voting_power += cns_calculate_vote_voting_power($vote['id'], $recalculate, $vote);
    }

    $GLOBALS['FORUM_DB']->query_update('f_poll_answers', ['pa_cache_voting_power' => $voting_power], ['id' => $answer_id]);

    return $voting_power;
}

/**
 * Calculate how much voting power a particular vote has, and cache it.
 *
 * @param  AUTO_LINK $vote_id The ID of the vote
 * @param  boolean $recalculate Recalculate even if a cached voting power already exists
 * @param  ?array $row The database row for the vote, if it was already queried elsewhere (null: query for it)
 * @return float The voting power for the specified vote
 */
function cns_calculate_vote_voting_power(int $vote_id, bool $recalculate = false, ?array $row = null) : float
{
    if ($row === null) {
        $_row = $GLOBALS['FORUM_DB']->query_select('f_poll_votes', ['id', 'pv_revoked', 'pv_points_when_voted', 'pv_cache_voting_power'], ['id' => $vote_id], '', 1);
        if ($_row === null || !array_key_exists(0, $_row)) {
            warn_exit(do_lang_tempcode('MISSING_RESOURCE'));
        }
        $row = $_row[0];
    }

    if ($row['pv_revoked'] == 1) {
        return 0.0;
    }

    if ($row['pv_cache_voting_power'] !== null && !$recalculate) {
        return $row['pv_cache_voting_power'];
    }

    $voting_power = cns_points_to_voting_power($row['pv_points_when_voted']);

    $GLOBALS['FORUM_DB']->query_update('f_poll_votes', ['pv_cache_voting_power' => $voting_power], ['id' => $vote_id]);

    return $voting_power;
}

/**
 * Calculate how much voting power a certain amount of points has.
 *
 * @param  integer $points The number of points from which to calculate the voting power
 * @param  array $overrides Array of option overrides (empty: no overrides)
 * @return float The amount of voting power associated with the points
 */
function cns_points_to_voting_power(int $points, array $overrides = []) : float
{
    // Default to 1 if any necessary addons are not installed
    if (!addon_installed('cns_forum')) {
        return 1.0;
    }
    if (!addon_installed('points')) {
        return 1.0;
    }

    $ceiling = isset($overrides['topic_polls_weighting_ceiling']) ? $overrides['topic_polls_weighting_ceiling'] : get_option('topic_polls_weighting_ceiling'); // Could be blank
    $offset = isset($overrides['topic_polls_weighting_offset']) ? intval($overrides['topic_polls_weighting_offset']) : intval(get_option('topic_polls_weighting_offset'));
    $multiplier = isset($overrides['topic_polls_weighting_multiplier']) ? abs(floatval($overrides['topic_polls_weighting_multiplier'])) : abs(floatval(get_option('topic_polls_weighting_multiplier')));
    $base = isset($overrides['topic_polls_weighting_base']) ? abs(floatval($overrides['topic_polls_weighting_base'])) : abs(floatval(get_option('topic_polls_weighting_base')));

    // Sanity check: If negative points, then member has no voting power. This avoids root of a negative number, which equals i.
    if ($points < 0) {
        return 0.0;
    }

    // Sanity check; if base is 1 or less, it should always be 1 so voting power is initially the number of points the member has.
    if ($base <= 1.0) {
        $base = 1.0;
    }

    // Voting power formula (!cns_polls:VOTING_POWER_EQUATION)
    $_voting_power = max(0, ($offset + ($multiplier * pow($points, (1 / $base)))));

    $voting_power = $_voting_power;

    // Max out at ceiling value
    if ($ceiling !== null && $ceiling !== '') {
        $voting_power = min(abs(intval($ceiling)), $_voting_power);
    }

    return $voting_power;
}

/**
 * Calculate how much voting power a certain amount of points has and return text versions of the calculations.
 *
 * @param  integer $points The number of points from which to calculate the voting power
 * @param  array $overrides Array of option overrides (empty: no overrides)
 * @return array Tuple; first item is a Tempcode of the equation itself, second item is a Tempcode with the numbers substituted into the equation, and third item is the final result
 */
function cns_calculate_poll_voting_power_text(int $points, array $overrides = []) : array
{
    // Return defaults if necessary addons are not installed
    if (!addon_installed('cns_forum')) {
        return [new Tempcode(), new Tempcode(), 1.0];
    }
    if (!addon_installed('points')) {
        return [new Tempcode(), new Tempcode(), 1.0];
    }

    require_lang('cns_polls');

    $ceiling = isset($overrides['topic_polls_weighting_ceiling']) ? $overrides['topic_polls_weighting_ceiling'] : get_option('topic_polls_weighting_ceiling'); // Could be blank
    $offset = isset($overrides['topic_polls_weighting_offset']) ? intval($overrides['topic_polls_weighting_offset']) : intval(get_option('topic_polls_weighting_offset'));
    $multiplier = isset($overrides['topic_polls_weighting_multiplier']) ? abs(floatval($overrides['topic_polls_weighting_multiplier'])) : abs(floatval(get_option('topic_polls_weighting_multiplier')));
    $base = isset($overrides['topic_polls_weighting_base']) ? abs(floatval($overrides['topic_polls_weighting_base'])) : abs(floatval(get_option('topic_polls_weighting_base')));

    // Give context for a blank ceiling in the equation text
    if ($ceiling === null || $ceiling == '') {
        $ceiling = "Infinity";
    }

    // Sanity check; if base is 1 or less, it should always be 1 so voting power is initially the number of points the member has.
    if ($base <= 1.0) {
        $base = 1.0;
    }

    $equation = do_lang_tempcode('VOTING_POWER_EQUATION', 'Voting power maximum', 'Offset', ['Multiplier', 'life-time points', 'Root base']);
    $equation_with_numbers = do_lang_tempcode('VOTING_POWER_EQUATION', escape_html($ceiling), escape_html(strval($offset)), [escape_html(float_to_raw_string($multiplier, 2, true)), escape_html(strval($points)), escape_html(float_to_raw_string($base, 2, true))]);
    $calculation = cns_points_to_voting_power($points, $overrides);

    return [$equation, $equation_with_numbers, $calculation];
}
