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

/**
 * Standard code module initialisation function.
 *
 * @ignore
 */
function init__points()
{
    // Initialise points cache
    global $POINT_INFO_CACHE;
    $POINT_INFO_CACHE = [];

    // Define constants for points_ledger_calculate
    if (!defined('LEDGER_TYPE_RECEIVED')) {
        define('LEDGER_TYPE_RECEIVED', 0x1); // (1 in decimal) Points received from anyone
    }
    if (!defined('LEDGER_TYPE_SENT')) {
        define('LEDGER_TYPE_SENT', 0x2); // (2 in decimal) Points sent to other members
    }
    if (!defined('LEDGER_TYPE_SPENT')) {
        define('LEDGER_TYPE_SPENT', 0x4); // (4 in decimal) Points spent to the system
    }
    if (!defined('LEDGER_TYPE__ALL')) {
        define('LEDGER_TYPE__ALL', LEDGER_TYPE_RECEIVED | LEDGER_TYPE_SENT | LEDGER_TYPE_SPENT);
    }

    // Define constants for points_ledger.status in the database
    if (!defined('LEDGER_STATUS_NORMAL')) {
        define('LEDGER_STATUS_NORMAL', 0); // Normal (active) transaction; should be included in point calculations
    }
    if (!defined('LEDGER_STATUS_REVERSED')) {
        define('LEDGER_STATUS_REVERSED', 1); // Transaction no longer valid; should be ignored in point calculations
    }
    if (!defined('LEDGER_STATUS_REVERSING')) {
        define('LEDGER_STATUS_REVERSING', 2); // Transaction reverses (invalidates) another transaction; should be ignored in point calculations
    }
    if (!defined('LEDGER_STATUS_REFUND')) {
        define('LEDGER_STATUS_REFUND', 3); // Transaction refunds part or all of the points from another; should be included in point transactions but should subtract from used points and gift points
    }
}

/**
 * Flush the points cache.
 *
 * @param  ?MEMBER $member_id Only flush the points cache for the specified member (null: flush all members)
 * @param  ?ID_TEXT $property Only flush this property from a member's points cache (null: flush all properties)
 * @set points_used points_spent points_sent points_received gift_points_sent
 */
function points_flush_runtime_cache(?int $member_id = null, ?string $property = null)
{
    global $POINT_INFO_CACHE;

    if (($member_id === null) && ($property === null)) { // Clear the entire points cache
        $POINT_INFO_CACHE = [];
    } elseif (($member_id !== null) && ($property === null)) { // Clear the points cache for a member
        $POINT_INFO_CACHE[$member_id] = [];
    } elseif (($member_id !== null) && ($property !== null)) { // Clear a specific cache on a specific member
        if (isset($POINT_INFO_CACHE[$member_id])) {
            unset($POINT_INFO_CACHE[$member_id][$property]);
        }
    } elseif (($member_id === null) && ($property !== null)) { // Clear a specific cache on all members
        foreach (array_keys($POINT_INFO_CACHE) as $member_id) {
            unset($POINT_INFO_CACHE[$member_id][$property]);
        }
    }

    // Clear member row cache
    $GLOBALS['FORUM_DRIVER']->MEMBER_ROWS_CACHED = [];
}

/**
 * Get the price of the specified item for sale (only for tableless items).
 *
 * @param  ID_TEXT $item The name of the item
 * @return integer The price of the item
 */
function get_product_price_points(string $item) : int
{
    return $GLOBALS['SITE_DB']->query_select_value('ecom_prods_prices', 'price_points', ['name' => $item]);
}

/**
 * Get the total number of points the member has received which contribute towards ranks.
 * Some of these points may have already been spent.
 *
 * @param  MEMBER $member_id The member
 * @param  ?TIME $timestamp Time to get for (null: now)
 * @param  boolean $cache Whether to retrieve from the CPF (always false if $timestamp is not null)
 * @return integer The number of points the member has
 */
function points_rank(int $member_id, ?int $timestamp = null, bool $cache = true) : int
{
    if (!has_privilege($member_id, 'use_points')) {
        return 0;
    }

    if ($timestamp === null && $cache) {
        $values = $GLOBALS['FORUM_DRIVER']->get_custom_fields($member_id);
        if ($values === null) {
            $values = [];
        }

        if (array_key_exists('points_rank', $values)) {
            return intval($values['points_rank']);
        }

        return 0;
    }

    $end = '';
    if ($timestamp !== null) {
        $end .= ' AND date_and_time<=' . strval($timestamp);
    }

    // Calculate total points ever received
    $_points = points_ledger_calculate(LEDGER_TYPE_RECEIVED, $member_id, null, $end);
    list(, $t_points, $t_gift_points) = $_points['received'];
    $points = ($t_points + $t_gift_points);

    // Points charged from warnings should be considered a punishment against life-time points (as life-time points is used in ranks)
    $_points = points_ledger_calculate(LEDGER_TYPE_SPENT, $member_id, null, ' AND (' . db_string_equal_to('t_type', 'warning') . ') AND (' . db_string_equal_to('t_subtype', 'add') . ')');
    list(, $t_points, $t_gift_points) = $_points['spent'];
    $points -= ($t_points + $t_gift_points);

    return intval($points);
}

/**
 * Get the total points the specified member has used (does not include gift points).
 *
 * @param  MEMBER $member_id The member
 * @return integer The number of points the member has spent
 */
function points_used(int $member_id) : int
{
    if (!has_privilege($member_id, 'use_points')) {
        return 0;
    }

    global $POINT_INFO_CACHE;
    if (isset($POINT_INFO_CACHE[$member_id]['points_used'])) {
        return $POINT_INFO_CACHE[$member_id]['points_used'];
    } elseif (!array_key_exists($member_id, $POINT_INFO_CACHE)) {
        $POINT_INFO_CACHE[$member_id] = [];
    }

    // Calculate total points ever used
    $_points = points_ledger_calculate(LEDGER_TYPE_SENT | LEDGER_TYPE_SPENT, $member_id);
    list(, $sent_points, ) = $_points['sent'];
    list(, $spent_points, ) = $_points['spent'];
    $points = ($sent_points + $spent_points);

    $POINT_INFO_CACHE[$member_id]['points_used'] = $points;

    return $points;
}

/**
 * Get the total points the specified member has spent to the system.
 *
 * @param  MEMBER $member_id The member
 * @return integer The number of points the member has spent to the system
 */
function points_spent(int $member_id) : int
{
    if (!has_privilege($member_id, 'use_points')) {
        return 0;
    }

    global $POINT_INFO_CACHE;
    if (isset($POINT_INFO_CACHE[$member_id]['points_spent'])) {
        return $POINT_INFO_CACHE[$member_id]['points_spent'];
    } elseif (!array_key_exists($member_id, $POINT_INFO_CACHE)) {
        $POINT_INFO_CACHE[$member_id] = [];
    }

    // Calculate total points ever spent to the system
    $_points = points_ledger_calculate(LEDGER_TYPE_SPENT, $member_id);
    list(, $spent_points, ) = $_points['spent'];
    $points = $spent_points;

    $POINT_INFO_CACHE[$member_id]['points_spent'] = $points;

    return $points;
}

/**
 * Get the number of points a member has to spend.
 *
 * @param  MEMBER $member_id The member
 * @return integer The number of points the member has to spend
 */
function points_balance(int $member_id) : int
{
    if (!has_privilege($member_id, 'use_points')) {
        return 0;
    }

    $values = $GLOBALS['FORUM_DRIVER']->get_custom_fields($member_id);
    if ($values === null) {
        $values = [];
    }

    if (array_key_exists('points_balance', $values)) {
        return @intval($values['points_balance']);
    }

    return 0;
}

/**
 * Get the number of gift points sent by the given member.
 *
 * @param  MEMBER $member_id The member we want it for
 * @return integer The number of gift points sent by the member to others
 */
function gift_points_sent(int $member_id) : int
{
    global $POINT_INFO_CACHE;
    if (isset($POINT_INFO_CACHE[$member_id]['gift_points_sent'])) {
        return $POINT_INFO_CACHE[$member_id]['gift_points_sent'];
    } elseif (!array_key_exists($member_id, $POINT_INFO_CACHE)) {
        $POINT_INFO_CACHE[$member_id] = [];
    }

    // Calculate gift points sent
    $_points = points_ledger_calculate(LEDGER_TYPE_SENT | LEDGER_TYPE_SPENT, $member_id);
    list(, , $sent_gift_points) = $_points['sent'];
    $points = $sent_gift_points;
    list(, , $spent_gift_points) = $_points['spent'];
    $points += $spent_gift_points;

    $POINT_INFO_CACHE[$member_id]['gift_points_sent'] = $points;

    return $points;
}

/**
 * Get the total points the provided member sent to other members (and not the system). This includes gift points.
 *
 * @param  MEMBER $member_id The member which to get the total sent points
 * @return integer The total number of points the member sent to other members
 */
function points_sent(int $member_id) : int
{
    if (!has_privilege($member_id, 'use_points')) {
        return 0;
    }

    global $POINT_INFO_CACHE;
    if (isset($POINT_INFO_CACHE[$member_id]['points_sent'])) {
        return $POINT_INFO_CACHE[$member_id]['points_sent'];
    } elseif (!array_key_exists($member_id, $POINT_INFO_CACHE)) {
        $POINT_INFO_CACHE[$member_id] = [];
    }

    // Calculate total points ever spent to the system
    $_points = points_ledger_calculate(LEDGER_TYPE_SENT, $member_id);
    list(, $sent_points, $sent_gift_points) = $_points['sent'];
    $points = ($sent_points + $sent_gift_points);

    $POINT_INFO_CACHE[$member_id]['points_sent'] = $points;

    return $points;
}

/**
 * Calculate the maximum number of gift points a member can possibly have based on usergroup membership and time since joining.
 *
 * @param  MEMBER $member_id The member to calculate
 * @return integer The maximum gift points the member can possibly have
 */
function gift_points_maximum(int $member_id) : int
{
    // If gift points is disabled, return 0.
    if (get_option('enable_gift_points') == '0') {
        return 0;
    }

    if (get_forum_type() == 'cns') {
        require_lang('cns');
        require_code('cns_groups');

        $base = cns_get_member_best_group_property($member_id, 'gift_points_base');
        $per_day = cns_get_member_best_group_property($member_id, 'gift_points_per_day');
    } else {
        $base = 25;
        $per_day = 1;
    }

    $points = $base + ($per_day * intval(floor((time() - $GLOBALS['FORUM_DRIVER']->get_member_join_timestamp($member_id)) / (60 * 60 * 24))));

    return $points;
}

/**
 * Get the number of gift points that the given member has which they can send to others.
 *
 * @param  MEMBER $member_id The member we want it for
 * @return integer The number of gifts points that the given member has
 */
function gift_points_balance(int $member_id) : int
{
    // If gift points is disabled, return 0.
    if (get_option('enable_gift_points') == '0') {
        return 0;
    }

    $gift_points_maximum = gift_points_maximum($member_id);
    $sent = gift_points_sent($member_id);

    return ($gift_points_maximum - $sent);
}

/**
 * Generate a proper URL to a member's points profile and optionally pre-populate fields for sending / modifying points.
 *
 * @param  MEMBER $member_id The member to view the profile
 * @param  boolean $skip_keep Whether to skip actually putting on keep_ parameters (rarely will this skipping be desirable)
 * @param  ?integer $send_amount The number of points to pre-populate in the send / modify form (null: leave blank)
 * @param  ?SHORT_TEXT $send_reason The reason to pre-populate in the send / modify form (null: leave blank)
 * @param  ?ID_TEXT $trans_type The type of transaction to pre-select (ignored for those without "Moderate points" privilege) (null: do not set a default)
 * @set send credit debit
 * @return Tempcode The URL to the member's points profile
 *
 */
function points_url(int $member_id, bool $skip_keep = false, ?int $send_amount = null, ?string $send_reason = null, ?string $trans_type = null) : object
{
    $map = [];
    if ($trans_type !== null) {
        $map['trans_type'] = $trans_type;
    }
    if ($send_amount !== null) {
        $map['send_amount'] = $send_amount;
    }
    if ($send_reason !== null) {
        $map['send_reason'] = $send_reason;
    }

    if (get_forum_type() == 'cns') {
        $url = $GLOBALS['FORUM_DRIVER']->member_profile_url($member_id, !$skip_keep, null, ['conversr_tab' => 'points', 'extra_get_params' => $map]);
        if (!is_object($url)) {
            $url = make_string_tempcode($url);
        }
    } else {
        $map = array_merge($map, ['page' => 'points', 'type' => 'member', 'id' => $member_id]);
        $url = build_url($map, get_module_zone('points'), [], false, false, $skip_keep);
    }

    return $url;
}

/**
 * Calculate the number of active transactions and number of points for the specified criteria.
 * This function accounts (adjusts) for reversed and refunded transactions as well.
 *
 * @param  integer $types The calculations we want to return (see LEDGER_TYPE_*)
 * @param  MEMBER $primary_member The member to calculate (received: The member receiving the points) (sent and spent: the member using the points)
 * @param  ?MEMBER $secondary_member Optionally filter to a secondary member (received: The member who sent the points to the primary member) (sent: The member who received the points from the primary member) (spent: ignored; always the system) (null: any member)
 * @param  LONG_TEXT $where Optionally add additional WHERE to the SQL (should start with ' AND')
 * @return array List of Tuples; key is a type from $types, value is the tuple of [count of active transactions matching criteria, number of points, number of gift points]
 */
function points_ledger_calculate(int $types, int $primary_member, ?int $secondary_member = null, string $where = '') : array
{
    // Invalid bitmask
    if ($types <= 0 || $types > 7) {
        return [];
    }

    $ret = [];

    // Build our queries
    $cases = 'CASE';
    $cases_refund = 'CASE';
    $case_when = '';
    $case_when_refund = '';
    if (($types & LEDGER_TYPE_RECEIVED) != 0) {
        $ret['received'] = [0, 0, 0];
        $case_when .= ' WHEN receiving_member=' . strval($primary_member);
        $case_when_refund = ' WHEN sending_member=' . strval($primary_member);
        if ($secondary_member !== null) {
            $case_when .= ' AND sending_member=' . strval($secondary_member);
            $case_when_refund .= ' AND receiving_member=' . strval($secondary_member);
        }
        $case_when .= ' THEN \'received\'';
        $case_when_refund .= ' THEN \'received\'';
    }
    if (($types & LEDGER_TYPE_SENT) != 0) {
        $ret['sent'] = [0, 0, 0];
        $case_when .= ' WHEN sending_member=' . strval($primary_member);
        $case_when_refund .= ' WHEN receiving_member=' . strval($primary_member);
        if ($secondary_member !== null) {
            $case_when .= ' AND receiving_member=' . strval($secondary_member);
            $case_when_refund .= ' AND sending_member=' . strval($secondary_member);
        } else {
            $case_when .= ' AND receiving_member<>' . strval($GLOBALS['FORUM_DRIVER']->get_guest_id());
            $case_when_refund .= ' AND sending_member<>' . strval($GLOBALS['FORUM_DRIVER']->get_guest_id());
        }
        $case_when .= ' THEN \'sent\'';
        $case_when_refund .= ' THEN \'sent\'';
    }
    if (($types & LEDGER_TYPE_SPENT) != 0) {
        $ret['spent'] = [0, 0, 0];
        $case_when .= ' WHEN sending_member=' . strval($primary_member);
        $case_when .= ' AND receiving_member=' . strval($GLOBALS['FORUM_DRIVER']->get_guest_id());
        $case_when .= ' THEN \'spent\'';
        $case_when_refund .= ' WHEN receiving_member=' . strval($primary_member);
        $case_when_refund .= ' AND sending_member=' . strval($GLOBALS['FORUM_DRIVER']->get_guest_id());
        $case_when_refund .= ' THEN \'spent\'';
    }
    $more_where = ' AND (receiving_member=' . strval($primary_member) . ' OR sending_member=' . strval($primary_member) . ')';
    if ($secondary_member !== null) {
        $more_where .= ' AND (receiving_member=' . strval($secondary_member) . ' OR sending_member=' . strval($secondary_member) . ')';
    }

    $cases .= $case_when . ' END AS transaction_type';
    $query_select = 'SELECT COUNT(*) as count, SUM(amount_points) as points, SUM(amount_gift_points) as gift_points, ' . $cases . ' FROM ' . $GLOBALS['SITE_DB']->get_table_prefix() . 'points_ledger';
    $cases_refund .= $case_when_refund . ' END AS transaction_type';
    $query_select_refund = 'SELECT COUNT(*) as count, SUM(amount_points) as points, SUM(amount_gift_points) as gift_points, ' . $cases_refund . ' FROM ' . $GLOBALS['SITE_DB']->get_table_prefix() . 'points_ledger';

    // Get our base calculation for all status=normal transactions matching criteria; we ignore reversed and reversing as they cancel each other 1:1 (refund transactions are handled in the next block)
    $rows = $GLOBALS['SITE_DB']->query($query_select . ' WHERE status=' . strval(LEDGER_STATUS_NORMAL) . $more_where . $where . ' GROUP BY transaction_type');
    foreach ($rows as $row) {
        if ($row['transaction_type'] === null) {
            continue; // Did not match any of our SELECT CASE conditions, so skip.
        }
        $ret[$row['transaction_type']] = [intval($row['count']), intval($row['points']), intval($row['gift_points'])];
    }

    // Get status=refund transactions for the same criteria and subtract them from our base calculation; these are usually partial refunds
    $rows = $GLOBALS['SITE_DB']->query($query_select_refund . ' WHERE status=' . strval(LEDGER_STATUS_REFUND) . $more_where . $where . ' GROUP BY transaction_type');
    foreach ($rows as $row) {
        if ($row['transaction_type'] === null) {
            continue; // Did not match any of our SELECT CASE conditions, so skip.
        }

        // Refund transactions are neither 'active' in of themselves nor do they cancel (reverse) a previous transaction (since they are usually partial refunds only). Thus, we probably want to leave count alone.
        // $ret[$row['transaction_type']][0] -= intval($row['count']);
        $ret[$row['transaction_type']][1] -= intval($row['points']);
        $ret[$row['transaction_type']][2] -= intval($row['gift_points']);
    }

    return $ret;
}
