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

/**
 * See if a leader-board has been calculated since a particular cut-off time.
 *
 * @param  array $row The leader-board row to check
 * @param  TIME $start The start time of the result set
 * @return boolean Whether it has
 */
function has_leader_board_since(array $row, int $start) : bool
{
    $test = $GLOBALS['SITE_DB']->query_value_if_there('SELECT COUNT(*) FROM ' . get_table_prefix() . 'leader_board WHERE id=' . strval($row['id']) . ' AND lb_date_and_time>=' . strval($start));
    return ($test > 0);
}

/**
 * Get the timestamp of the most recent leader-board result set.
 *
 * @param  AUTO_LINK $id ID of the leader-board
 * @return ?TIME The timestamp of the most recent result set (null: no result sets generated)
 */
function most_recent_leader_board(int $id) : ?int
{
    return $GLOBALS['SITE_DB']->query_select_value_if_there('leader_board', 'MAX(lb_date_and_time)', ['lb_leader_board_id' => $id]);
}

/**
 * Determine how many leader-boards are pending generation of a new result set.
 *
 * @return integer Number of leader-boards pending generation
 */
function get_leader_board_calculation_count() : int
{
    $count = 0;

    $rows = $GLOBALS['SITE_DB']->query_select('leader_boards', ['*'], [], '');
    foreach ($rows as $row) {
        list($start, $end) = _get_next_leader_board_timeframe($row);
        if ($start !== null && $end !== null) {
            $count++;
        }
    }

    return $count;
}

/**
 * Check all leader-boards and generate new result sets when applicable.
 *
 * @param  ?TIME $forced_time Pretend the current time is the provided timestamp (null: use the current time in the site's timezone)
 * @param  ?TIME $forced_period_start Pretend the most recent result set was generated on the provided timestamp (null: use the actual most recent timestamp)
 * @return array Map of leader_board IDs to their new result set timestamp
 */
function calculate_all_leader_boards(?int $forced_time = null, ?int $forced_period_start = null) : array
{
    $leader_boards = [];

    $rows = $GLOBALS['SITE_DB']->query_select('leader_boards', ['*'], [], 'ORDER BY id');
    foreach ($rows as $row) {
        $timestamp = calculate_leader_board($row, $forced_time, $forced_period_start);
        $leader_boards[$row['id']] = $timestamp;
    }

    return $leader_boards;
}

/**
 * Calculate a leader-board.
 *
 * @param  array $row The leader-board row to calculate
 * @param  ?TIME $forced_time Pretend the current time is the provided timestamp (null: use the current time in the site's timezone)
 * @param  ?TIME $forced_period_start Pretend the most recent result set was generated on the provided timestamp (null: use the actual most recent timestamp)
 * @return ?TIME The timestamp of the result set (null: a new result set was not generated)
 */
function calculate_leader_board(array $row, ?int $forced_time = null, ?int $forced_period_start = null) : ?int
{
    require_code('points');

    // Calculate our result set times
    list($start, $end) = _get_next_leader_board_timeframe($row, $forced_time, $forced_period_start);
    if ($start === null || $end === null) {
        return null;
    }

    $use_voting_power = ((addon_installed('cns_forum')) && (get_forum_type() == 'cns') && (get_option('enable_poll_point_weighting') == '1') && ($row['lb_calculate_voting_power'] == 1));
    if ($use_voting_power) {
        $total_voting_power = 0.0;
        require_code('cns_polls_action2');

        // Calculate total site-wide voting power so we can derive voting control values
        $member_id = null;
        do {
            $rows = $GLOBALS['FORUM_DRIVER']->get_next_members($member_id, 100);
            foreach ($rows as $mrow) {
                $member_id = $GLOBALS['FORUM_DRIVER']->mrow_member_id($mrow);

                if (is_guest($member_id)) {
                    continue;
                }

                $total_voting_power += cns_points_to_voting_power(points_balance($member_id));
            }
        } while (count($rows) > 0);
    } else {
        $total_voting_power = null;
    }

    $limit = $row['lb_member_count']; // The number to show on the leader-board
    $show_staff = ($row['lb_include_staff'] == 1); // Whether to include staff

    // Determine usergroups to filter
    $usergroups = $GLOBALS['SITE_DB']->query_select('leader_boards_groups', ['*'], ['lb_leader_board_id' => $row['id']]);
    $usergroups = collapse_1d_complexity('lb_group', $usergroups);

    // Calculate points earned within the range (accounting for refunds), grouping by member
    $_points_earned = $GLOBALS['SITE_DB']->query_select('points_ledger', ['receiving_member', 'SUM(amount_points) AS points', 'SUM(amount_gift_points) AS gift_points'], ['status' => LEDGER_STATUS_NORMAL], ' AND date_and_time>=' . strval($start) . ' AND date_and_time<' . strval($end) . ' GROUP BY receiving_member');
    $points_earned = list_to_map('receiving_member', $_points_earned);
    $_points_refunded = $GLOBALS['SITE_DB']->query_select('points_ledger', ['sending_member', 'SUM(amount_points) AS points', 'SUM(amount_gift_points) AS gift_points'], ['status' => LEDGER_STATUS_REFUND], ' AND date_and_time>=' . strval($start) . ' AND date_and_time<' . strval($end) . ' GROUP BY sending_member');
    $points_refunded = list_to_map('sending_member', $_points_refunded);
    foreach ($points_earned as $member_id => $prow) {
        $points_earned[$member_id]['total_points'] = (@intval($prow['points']) + @intval($prow['gift_points']));
        if (isset($points_refunded[$member_id])) {
            $points_earned[$member_id]['total_points'] -= (@intval($points_refunded[$member_id]['points']) + @intval($points_refunded[$member_id]['gift_points']));
        }
    }

    // Now, process our leaderboard records
    $points = [];
    foreach ($points_earned as $member_id => $prow) {
        if (is_guest($member_id)) {
            continue; // Should not happen, but some forum drivers might suck ;)
        }

        // Skip if staff and not including staff
        if ((!$show_staff) && ($GLOBALS['FORUM_DRIVER']->is_staff($member_id))) {
            continue;
        }

        // Skip if usergroups are defined and member is not in any of the defined usergroups
        if ((get_forum_type() == 'cns') && (!empty($usergroups)) && (count(array_intersect($usergroups, $GLOBALS['FORUM_DRIVER']->get_members_groups($member_id))) == 0)) {
            continue;
        }

        // By this point, member qualifies for the leader-board
        $points[] = ['member_id' => $member_id, 'points' => $prow['total_points']];
    }

    // Add a dummy system record if there are no other records; since leader-boards judge last generation based on result dates, we need to put something in the database.
    if (count($points) == 0) {
        $points[] = ['member_id' => $GLOBALS['FORUM_DRIVER']->get_guest_id(), 'points' => 0];
    }

    // Sort the array according to points (highest to lowest)
    sort_maps_by($points, '!points');

    // Construct the leader-board results
    $i = 0;
    foreach ($points as $v) {
        if ($i >= $limit) {
            break;
        }

        if ($use_voting_power) {
            $voting_power = cns_points_to_voting_power(points_balance($v['member_id']));
            $voting_control = (($total_voting_power > 0.0) ? (($voting_power / $total_voting_power) * 100) : 100);
        } else {
            $voting_power = null;
            $voting_control = null;
        }

        $result_row = [
            'lb_member' => $v['member_id'],
            'lb_points' => $v['points'],
            'lb_voting_power' => $voting_power,
            'lb_voting_control' => $voting_control,
            'lb_rank' => $i + 1,
            'lb_leader_board_id' => $row['id'],
            'lb_date_and_time' => $end,
        ];
        $GLOBALS['SITE_DB']->query_insert('leader_board', $result_row);

        $i++;
    }

    return $end;
}

/**
 * Get a result set for a given leader-board.
 * This ignores dummy guest entries as they only serve to indicate an empty leader-board was generated.
 *
 * @param  AUTO_LINK $id The ID of the leader-board
 * @param  ?TIME $timestamp The timestamp of the result set to fetch (null: fetch the latest result set)
 * @return array The result set, or empty if not found
 */
function get_leader_board(int $id, ?int $timestamp = null) : array
{
    if ($timestamp === null) {
        $timestamp = most_recent_leader_board($id);
        if ($timestamp === null) { // If timestamp is still null, we probably do not have any result sets, so return empty array.
            return [];
        }
    }

    // Get result set ordered by rank
    return $GLOBALS['SITE_DB']->query_select('leader_board', ['*'], ['lb_leader_board_id' => $id, 'lb_date_and_time' => $timestamp], ' AND lb_member<>' . strval($GLOBALS['FORUM_DRIVER']->get_guest_id()) . ' ORDER BY lb_rank');
}

/**
 * Determine the start and end times for the next result set that needs generating for a leader-board.
 *
 * @param  array $row The leader-board row
 * @param  ?TIME $forced_time Pretend the current time is the provided timestamp (null: use the current time in the site's timezone)
 * @param  ?TIME $forced_period_start Pretend the most recent result set was generated on the provided timestamp (null: use the actual most recent timestamp)
 * @return array A ?TIME duple containing start and end; both will be null if this leader-board should not generate a result set at this time
 */
function _get_next_leader_board_timeframe(array $row, ?int $forced_time = null, ?int $forced_period_start = null) : array
{
    // Determine the timestamp of the most recent result set. If there are no result sets, we start at the timestamp the leader-board was created.
    if ($forced_period_start !== null) {
        $recent = $forced_period_start;
    } else {
        $recent = most_recent_leader_board($row['id']);
        if ($recent === null) {
            $recent = 0; // Forces us to retroactively generate a first result set right away for new leader-boards; date will be fixed in the switch below.
        }
    }

    $now = ($forced_time !== null) ? $forced_time : time();
    $start = null;
    $end = $now;

    $rolling = ($row['lb_rolling'] == 1);

    $ssw = (get_option('ssw') == '1');

    // Set PHP to site timezone temporarily
    $old_timezone = @date_default_timezone_get();
    date_default_timezone_set(get_site_timezone());

    // Calculate the expected start and end time for our result set
    switch ($row['lb_timeframe']) {
        case 'week':
            $start = $recent;
            if ($ssw) {
                // Do not generate leader-boards from 2 or more weeks ago; just skip to the current week
                if (($now - $recent) >= (60 * 60 * 24 * 7 * 2)) {
                    if ($rolling) {
                        $start = strtotime('-1 week', (($recent > 0) ? $recent : $row['lb_creation_date_and_time']));
                    } else {
                        $start = strtotime('last sunday -1 week', $now);
                    }
                } elseif (!$rolling) {
                    $start = strtotime('sunday', $recent);
                }
            } else {
                // Do not generate leader-boards from 2 or more weeks ago; just skip to the current week
                if (($now - $recent) >= (60 * 60 * 24 * 7 * 2)) {
                    if ($rolling) {
                        $start = strtotime('-1 week', (($recent > 0) ? $recent : $row['lb_creation_date_and_time']));
                    } else {
                        $start = strtotime('last monday -1 week', $now);
                    }
                } elseif (!$rolling) {
                    $start = strtotime('monday', $recent);
                }
            }
            $end = strtotime('+1 week', $start);
            break;

        case 'month':
            // TODO: #6001 month operations are extremely unreliable; this has been disabled until it can be fixed
            /*
            $start = $recent;
            // Do not generate leader-boards from 2 or more months ago; just skip to the current week
            if ((intval(date('Y', $now)) * 12 + intval(date('m', $now))) - (intval(date('Y', $recent)) * 12 + intval(date('m', $recent))) >= 2) {
                if ($rolling) {
                    $start = strtotime('-1 month', (($recent > 0) ? $recent : $row['lb_creation_date_and_time']));
                } else {
                    $start = strtotime('first day of this month -1 month', $now);
                }
            } elseif (!$rolling) {
                $start = strtotime('first day of this month', $recent);
            }
            $end = strtotime('+1 month', $start);
            break;
            */
            return [null, null];

        case 'year':
            $start = $recent;
            // Do not generate leader-boards from 2 or more years ago; just generate the most recent one
            if (intval(date('Y', $now)) - intval(date('Y', $recent)) >= 2) {
                if ($rolling) {
                    $start = strtotime('-1 year', (($recent > 0) ? $recent : $row['lb_creation_date_and_time']));
                } else {
                    $start = strtotime('January 1 -1 year', $now);
                }
            } elseif (!$rolling) {
                $start = strtotime('January 1', $recent);
            }
            $end = strtotime('+1 year', $start);
            break;

        default:
            fatal_exit(do_lang_tempcode('INTERNAL_ERROR'));
    }

    // Re-set PHP timezone back to its previous setting
    date_default_timezone_set($old_timezone);

    // Do not generate if it is too soon unless the leader-board does not have a result set yet
    if (($now < $end) || ($recent == 0)) {
        return [null, null];
    }

    // If this is a holders leader-board, start should be epoch to calculate overall points balance.
    if ($row['lb_type'] == 'holders') {
        $start = 0;
    }

    return [$start, $end];
}
