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

/**
 * Module page class.
 */
class Module_points

{
    /**
     * Find details of the module.
     *
     * @return ?array Map of module info (null: module is disabled)
     */
    public function info() : ?array
    {
        $info = [];
        $info['author'] = 'Chris Graham';
        $info['organisation'] = 'Composr';
        $info['hacked_by'] = null;
        $info['hack_version'] = null;
        $info['version'] = 12;
        $info['locked'] = true;
        $info['update_require_upgrade'] = true;
        $info['min_cms_version'] = 11.0;
        $info['addon'] = 'points';
        return $info;
    }

    /**
     * Uninstall the module.
     */
    public function uninstall()
    {
        $tables = [
            'points_ledger',
            'escrow',
            'escrow_logs',
        ];
        $GLOBALS['SITE_DB']->drop_table_if_exists($tables);

        $privileges = [
            'send_points_to_self',
            'view_points_ledger',
            'use_points',
            'trace_anonymous_points_transactions',
            'use_points_escrow',
            'moderate_points_escrow',
            'send_points',
            'moderate_points',
            'amend_point_transactions',
        ];
        delete_privilege($privileges);

        $GLOBALS['FORUM_DRIVER']->install_delete_custom_field('points_balance');
        $GLOBALS['FORUM_DRIVER']->install_delete_custom_field('points_rank');
    }

    /**
     * Install the module.
     *
     * @param  ?integer $upgrade_from What version we're upgrading from (null: new install)
     * @param  ?integer $upgrade_from_hack What hack version we're upgrading from (null: new-install/not-upgrading-from-a-hacked-version)
     */
    public function install(?int $upgrade_from = null, ?int $upgrade_from_hack = null)
    {
        require_code('points');

        if ($upgrade_from === null) {
            $GLOBALS['SITE_DB']->create_table('points_ledger', [
                'id' => '*AUTO',
                'date_and_time' => 'TIME',
                'amount_gift_points' => 'INTEGER',
                'amount_points' => 'INTEGER',
                'sending_member' => 'MEMBER',
                'receiving_member' => 'MEMBER',
                'reason' => 'SHORT_TRANS__COMCODE',
                'anonymous' => 'BINARY',

                'status' => 'INTEGER', // See LEDGER_STATUS_* in points.php
                'linked_ledger_id' => '?AUTO_LINK',
                'locked' => 'BINARY', // 1 = status cannot be changed
                'is_ranked' => 'BINARY', // Whether this transaction counts towards the member's rank points (to system = affects sender negatively; from system or transaction between members = affects recipient)

                // FUDGE: These fields are not preserved in cms_merge at this time (e.g. auto-reversing from content deletion will not work)
                't_type' => 'ID_TEXT', // Content type or category of transaction
                't_subtype' => 'ID_TEXT', // Content action or subcategory of transaction
                't_type_id' => 'ID_TEXT', // Content or row ID of t_type
            ]);
            $GLOBALS['SITE_DB']->create_index('points_ledger', 'amount_gift_points', ['amount_gift_points']); // admin_points
            $GLOBALS['SITE_DB']->create_index('points_ledger', 'linked_ledger_id', ['linked_ledger_id']);
            $GLOBALS['SITE_DB']->create_index('points_ledger', 'status', ['status']);

            // t_* searching indexes; t_subtype and t_type_id require t_type for context and so are not indexed independently
            $GLOBALS['SITE_DB']->create_index('points_ledger', 't_search_subtype', ['t_type', 't_subtype', 't_type_id']); // Hierarchical structure of t_* searching: we can search t_type, t_type & t_subtype, or t_type, t_subtype, & t_type_id
            $GLOBALS['SITE_DB']->create_index('points_ledger', 't_search_no_subtype', ['t_type', 't_type_id']); // We can also search t_type & t_type_id, but this requires a separate index

            add_privilege('POINTS', 'use_points', true);
            add_privilege('POINTS', 'trace_anonymous_points_transactions', false);
            add_privilege('POINTS', 'send_points_to_self', false);
            add_privilege('POINTS', 'view_points_ledger', false);
        }

        if (($upgrade_from !== null) && ($upgrade_from < 8)) { // LEGACY
            $GLOBALS['SITE_DB']->alter_table_field('chargelog', 'user_id', 'MEMBER', 'member_id');
        }

        if (($upgrade_from !== null) && ($upgrade_from == 9/*upgrade conflict if we do < 10*/)) { // LEGACY: 11.beta1 from 11.alpha
            // Database consistency fixes
            $GLOBALS['SITE_DB']->alter_table_field('points_ledger', 'sender_id', 'MEMBER', 'sending_member');
            $GLOBALS['SITE_DB']->alter_table_field('points_ledger', 'recipient_id', 'MEMBER', 'receiving_member');
            $GLOBALS['SITE_DB']->alter_table_field('points_ledger', 'linked_to', '?AUTO_LINK', 'linked_ledger_id');
            $GLOBALS['SITE_DB']->alter_table_field('escrow', 'sender_id', 'MEMBER', 'sending_member');
            $GLOBALS['SITE_DB']->alter_table_field('escrow', 'recipient_id', 'MEMBER', 'receiving_member');
            $GLOBALS['SITE_DB']->alter_table_field('escrow', 'expiration', '?TIME', 'expiration_time');

            $GLOBALS['SITE_DB']->create_index('points_ledger', 'linked_ledger_id', ['linked_ledger_id']);
            $GLOBALS['SITE_DB']->create_index('escrow', 'sending_member', ['sending_member']);
            $GLOBALS['SITE_DB']->create_index('escrow', 'receiving_member', ['receiving_member']);
        }

        if (($upgrade_from !== null) && ($upgrade_from < 10)) { // LEGACY
            $GLOBALS['SITE_DB']->delete_index_if_exists('points_ledger', 'linked_to');
            $GLOBALS['SITE_DB']->delete_index_if_exists('escrow', 'sender_id');
            $GLOBALS['SITE_DB']->delete_index_if_exists('escrow', 'recipient_id');
        }

        if (($upgrade_from !== null) && ($upgrade_from < 9)) { // LEGACY
            global $CUSTOM_FIELD_CACHE, $MEMBER_CACHE_FIELD_MAPPINGS;
            $CUSTOM_FIELD_CACHE = [];
            $MEMBER_CACHE_FIELD_MAPPINGS = [];

            $GLOBALS['SITE_DB']->rename_table('gifts', 'points_ledger');

            $GLOBALS['SITE_DB']->add_table_field('points_ledger', 'amount_points', 'INTEGER');
            $GLOBALS['SITE_DB']->add_table_field('points_ledger', 'linked_ledger_id', '?AUTO_LINK');
            $GLOBALS['SITE_DB']->add_table_field('points_ledger', 'status', 'INTEGER');
            $GLOBALS['SITE_DB']->add_table_field('points_ledger', 'locked', 'BINARY');
            $GLOBALS['SITE_DB']->add_table_field('points_ledger', 't_type', 'ID_TEXT');
            $GLOBALS['SITE_DB']->add_table_field('points_ledger', 't_subtype', 'ID_TEXT');
            $GLOBALS['SITE_DB']->add_table_field('points_ledger', 't_type_id', 'ID_TEXT');

            $GLOBALS['SITE_DB']->alter_table_field('points_ledger', 'amount', 'INTEGER', 'amount_gift_points');
            $GLOBALS['SITE_DB']->alter_table_field('points_ledger', 'gift_from', 'MEMBER', 'sending_member');
            $GLOBALS['SITE_DB']->alter_table_field('points_ledger', 'gift_to', 'MEMBER', 'receiving_member');

            $GLOBALS['SITE_DB']->create_index('points_ledger', 'linked_ledger_id', ['linked_ledger_id']);
            $GLOBALS['SITE_DB']->create_index('points_ledger', 'status', ['status']);
            $GLOBALS['SITE_DB']->create_index('points_ledger', 'amount_gift_points', ['amount_gift_points']); // admin_points

            // t_* searching indexes; t_subtype and t_type_id require t_type for context and so are not indexed independently
            $GLOBALS['SITE_DB']->create_index('points_ledger', 't_search_subtype', ['t_type', 't_subtype', 't_type_id']); // Hierarchical structure of t_* searching: we can search t_type, t_type & t_subtype, or t_type, t_subtype, & t_type_id
            $GLOBALS['SITE_DB']->create_index('points_ledger', 't_search_no_subtype', ['t_type', 't_type_id']); // We can also search t_type & t_type_id, but this requires a separate index

            // Add legacy explanation and default values for all the gift records
            $GLOBALS['SITE_DB']->query_update('points_ledger', ['amount_points' => 0, 'status' => LEDGER_STATUS_NORMAL, 'locked' => 0, 't_type' => 'legacy', 't_type_id' => 'gifts'], []);

            // Never allow negative points in our new ledger; update to the absolute value, swap sender and recipient, and mark as refund.
            $GLOBALS['SITE_DB']->query('UPDATE ' . get_table_prefix() . 'points_ledger SET amount_gift_points=' . db_function('ABS', ['amount_gift_points']) . ', sending_member=receiving_member, receiving_member=sending_member, status=' . strval(LEDGER_STATUS_REFUND) . ' WHERE amount_gift_points<0');

            // Migrate all charge-log entries to points_ledger, and delete the chargelog table
            $start = 0;
            do {
                $chargelogs = $GLOBALS['SITE_DB']->query_select('chargelog', ['*'], [], '', 500, $start);
                foreach ($chargelogs as $i => $chargelog) {
                    if ($chargelog['amount'] < 0) { // For negative amounts, it is a credit; reverse sender and recipient (we do absolute value in the query_insert)
                        $sending_member = $GLOBALS['FORUM_DRIVER']->get_guest_id();
                        $receiving_member = $chargelog['member_id'];
                        $status = LEDGER_STATUS_REFUND;
                    } else {
                        $receiving_member = $GLOBALS['FORUM_DRIVER']->get_guest_id();
                        $sending_member = $chargelog['member_id'];
                        $status = LEDGER_STATUS_NORMAL;
                    }
                    $map = [
                        'date_and_time' => $chargelog['date_and_time'],
                        'amount_gift_points' => 0,
                        'amount_points' => abs($chargelog['amount']),
                        'sending_member' => $sending_member,
                        'receiving_member' => $receiving_member,
                        'anonymous' => 0,
                        'linked_ledger_id' => null,
                        't_type' => 'legacy',
                        't_subtype' => '',
                        't_type_id' => 'chargelog',
                        'status' => $status,
                        'locked' => 0,
                        'is_ranked' => 1, // Will get adjusted in module v12 upgrade code
                    ];
                    $map += insert_lang_comcode('reason', $chargelog['reason'], 3);
                    $GLOBALS['SITE_DB']->query_insert('points_ledger', $map);
                }

                $start += 500;
            } while (array_key_exists(0, $chargelogs));
            unset($chargelogs);

            $GLOBALS['SITE_DB']->drop_table_if_exists('chargelog');

            delete_privilege('have_negative_gift_points');
            delete_privilege('give_negative_points');

            rename_privilege('give_points_self', 'send_points_to_self');
            rename_privilege('view_charge_log', 'view_points_ledger');
            rename_privilege('trace_anonymous_gifts', 'trace_anonymous_points_transactions');

            // Delete the points_gained_given field but not before checking if we need to add a legacy record to the ledger.
            $start = 0;
            do {
                $rows = $GLOBALS['SITE_DB']->query_select('points_ledger', ['SUM(amount_points) AS points', 'SUM(amount_gift_points) AS gift_points', 'receiving_member'], [], ' AND receiving_member<>' . strval($GLOBALS['FORUM_DRIVER']->get_guest_id()) . ' GROUP BY receiving_member', 100, $start);
                foreach ($rows as $row) {
                    $fields = $GLOBALS['FORUM_DRIVER']->get_custom_fields($row['receiving_member']);
                    if (!isset($fields['points_gained_given'])) {
                        continue;
                    }
                    $amount = ($row['points'] + $row['gift_points']);
                    if ($amount !== intval($fields['points_gained_given'])) {
                        $difference = (intval($fields['points_gained_given']) - $amount);
                        $map = [
                            'sending_member' => ($difference < 0) ? $row['receiving_member'] : $GLOBALS['FORUM_DRIVER']->get_guest_id(),
                            'receiving_member' => ($difference < 0) ? $GLOBALS['FORUM_DRIVER']->get_guest_id() : $row['receiving_member'],
                            'amount_gift_points' => 0,
                            'amount_points' => abs($difference),
                            'date_and_time' => time(),
                            'anonymous' => 0,
                            'linked_ledger_id' => null,
                            't_type' => 'legacy',
                            't_subtype' => 'upgrader',
                            't_type_id' => 'points_gained_given',
                            'status' => ($difference < 0) ? LEDGER_STATUS_REFUND : LEDGER_STATUS_NORMAL, // Refunding gained points back to the system
                            'locked' => 0,
                            'is_ranked' => 1, // Will get adjusted in module v12 upgrade code
                        ];
                        $map += insert_lang_comcode('reason', 'Upgrader: Fixing Points-Gained-Given discrepancy', 4);
                        $GLOBALS['SITE_DB']->query_insert('points_ledger', $map);
                    }
                }

                unset($fields);
                $CUSTOM_FIELD_CACHE = [];
                $MEMBER_CACHE_FIELD_MAPPINGS = [];

                $start += 100;
            } while (array_key_exists(0, $rows));
            unset($rows);

            $GLOBALS['FORUM_DRIVER']->install_delete_custom_field('points_gained_given');

            // Delete the points_used and gift_points_used fields but not before checking if we need to add a legacy record to the ledger.
            $start = 0;
            do {
                $rows = $GLOBALS['SITE_DB']->query_select('points_ledger', ['SUM(amount_points) AS points', 'SUM(amount_gift_points) AS gift_points', 'sending_member'], [], ' AND sending_member<>' . strval($GLOBALS['FORUM_DRIVER']->get_guest_id()) . ' GROUP BY sending_member', 100, $start);
                foreach ($rows as $row) {
                    $fields = $GLOBALS['FORUM_DRIVER']->get_custom_fields($row['sending_member']);
                    if (isset($fields['points_used'])) {
                        if ($row['points'] !== intval($fields['points_used'])) {
                            $difference = (intval($fields['points_used']) - $row['points']);
                            $map = [
                                'sending_member' => ($difference < 0) ? $GLOBALS['FORUM_DRIVER']->get_guest_id() : $row['sending_member'],
                                'receiving_member' => ($difference < 0) ? $row['sending_member'] : $GLOBALS['FORUM_DRIVER']->get_guest_id(),
                                'amount_gift_points' => 0,
                                'amount_points' => abs($difference),
                                'date_and_time' => time(),
                                'anonymous' => 0,
                                'linked_ledger_id' => null,
                                't_type' => 'legacy',
                                't_subtype' => 'upgrader',
                                't_type_id' => 'points_used',
                                'status' => ($difference < 0) ? LEDGER_STATUS_REFUND : LEDGER_STATUS_NORMAL,
                                'locked' => 0,
                                'is_ranked' => 1, // Will get adjusted in module v12 upgrade code
                            ];
                            $map += insert_lang_comcode('reason', 'Upgrader: Fixing Points-Used discrepancy', 4);
                            $GLOBALS['SITE_DB']->query_insert('points_ledger', $map);
                        }
                    }
                    if (isset($fields['gift_points_used'])) {
                        if ($fields['gift_points_used'] !== intval($row['gift_points'])) {
                            $difference = (intval($fields['gift_points_used']) - $row['gift_points']);
                            $map = [
                                'sending_member' => ($difference < 0) ? $GLOBALS['FORUM_DRIVER']->get_guest_id() : $row['sending_member'],
                                'receiving_member' => ($difference < 0) ? $row['sending_member'] : $GLOBALS['FORUM_DRIVER']->get_guest_id(),
                                'amount_gift_points' => abs($difference),
                                'amount_points' => 0,
                                'date_and_time' => time(),
                                'anonymous' => 0,
                                'linked_ledger_id' => null,
                                't_type' => 'legacy',
                                't_subtype' => 'upgrader',
                                't_type_id' => 'gift_points_used',
                                'status' => ($difference < 0) ? LEDGER_STATUS_REFUND : LEDGER_STATUS_NORMAL,
                                'locked' => 0,
                                'is_ranked' => 1, // Will get adjusted in module v12 upgrade code
                            ];
                            $map += insert_lang_comcode('reason', 'Upgrader: Fixing Gift-Points-Used discrepancy', 4);
                            $GLOBALS['SITE_DB']->query_insert('points_ledger', $map);
                        }
                    }
                }

                unset($fields);
                $CUSTOM_FIELD_CACHE = [];
                $MEMBER_CACHE_FIELD_MAPPINGS = [];

                $start += 100;
            } while (array_key_exists(0, $rows));
            unset($rows);

            $GLOBALS['FORUM_DRIVER']->install_delete_custom_field('points_used');
            $GLOBALS['FORUM_DRIVER']->install_delete_custom_field('gift_points_used');

            // Add legacy records for the other custom fields, and remove them. We do not use the Composr API because this is very memory intensive.
            $deprecated_fields = [
                'points_gained_chat' => 'Points Gained Chat',
                'points_gained_visiting' => 'Points Gained Visiting',
                'points_gained_rating' => 'Points Gained Rating',
                'points_gained_voting' => 'Points Gained Voting',
                'points_gained_wiki' => 'Points Gained Wiki',
            ];
            $field_ids = [
                'points_gained_chat' => $GLOBALS['FORUM_DB']->query_select_value_if_there('f_custom_fields', 'id', ['cf_name' => 'cms_points_gained_chat']),
                'points_gained_visiting' => $GLOBALS['FORUM_DB']->query_select_value_if_there('f_custom_fields', 'id', ['cf_name' => 'cms_points_gained_visiting']),
                'points_gained_rating' => $GLOBALS['FORUM_DB']->query_select_value_if_there('f_custom_fields', 'id', ['cf_name' => 'cms_points_gained_rating']),
                'points_gained_voting' => $GLOBALS['FORUM_DB']->query_select_value_if_there('f_custom_fields', 'id', ['cf_name' => 'cms_points_gained_voting']),
                'points_gained_wiki' => $GLOBALS['FORUM_DB']->query_select_value_if_there('f_custom_fields', 'id', ['cf_name' => 'cms_points_gained_wiki']),
            ];

            $select_list = [];
            foreach ($field_ids as $field => $field_id) {
                if ($field_id !== null) {
                    $select_list[$field] = 'field_' . strval($field_id);
                }
            }

            if (count($select_list) > 0) {
                $start = 0;
                do {
                    $rows = $GLOBALS['FORUM_DB']->query_select('f_member_custom_fields', ['*'], [], '', 50, $start);
                    foreach ($rows as $row) {
                        foreach ($select_list as $field => $field_id) {
                            if (($row[$field_id] === null) || ($row[$field_id] == '')) {
                                continue;
                            }

                            $map = [
                                'sending_member' => $GLOBALS['FORUM_DRIVER']->get_guest_id(),
                                'receiving_member' => $row['mf_member_id'],
                                'amount_gift_points' => 0,
                                'amount_points' => intval($row[$field_id]),
                                'date_and_time' => time(),
                                'anonymous' => 0,
                                'linked_ledger_id' => null,
                                't_type' => 'legacy',
                                't_subtype' => 'upgrader',
                                't_type_id' => $field,
                                'status' => LEDGER_STATUS_NORMAL,
                                'locked' => 0,
                                'is_ranked' => 1, // Will get adjusted in module v12 upgrade code
                            ];
                            if (intval($row[$field_id]) < 0) { // Never have negative points
                                $sending_member = $map['sending_member'];
                                $receiving_member = $map['receiving_member'];
                                $map['sending_member'] = $receiving_member;
                                $map['receiving_member'] = $sending_member;
                                $map['amount_points'] = abs(intval($row[$field_id]));
                                $map['status'] = LEDGER_STATUS_REFUND; // Refunding earned points back to the system
                            }
                            $map += insert_lang_comcode('reason', 'Upgrader: Importing legacy ' . $deprecated_fields[$field] . ' as a ledger item', 4);
                            $GLOBALS['SITE_DB']->query_insert('points_ledger', $map);
                        }
                    }

                    $start += 50;
                } while (count($rows) > 0);
            }
            unset($rows);

            foreach ($deprecated_fields as $field => $title) {
                $GLOBALS['FORUM_DRIVER']->install_delete_custom_field($field);
            }
        }

        if (($upgrade_from === null) || ($upgrade_from < 9)) {
            $GLOBALS['SITE_DB']->create_index('points_ledger', 'send_to', ['sending_member', 'receiving_member']);
            $GLOBALS['SITE_DB']->create_index('points_ledger', 'receive_from', ['receiving_member', 'sending_member']);
            $GLOBALS['SITE_DB']->create_index('points_ledger', 'points', ['amount_points', 'amount_gift_points']);

            $GLOBALS['SITE_DB']->create_table('escrow', [
                'id' => '*AUTO',
                'date_and_time' => 'TIME',
                'update_date_and_time' => 'TIME',
                'amount' => 'INTEGER',
                'original_points_ledger_id' => 'AUTO_LINK', // This will always point to the first ledger id (when the escrow was created / points sent to the system)
                'sending_member' => 'MEMBER',
                'receiving_member' => '?MEMBER',
                'reason' => 'SHORT_TRANS__COMCODE',
                'agreement' => 'LONG_TRANS__COMCODE',
                'expiration_time' => '?TIME',
                'sender_status' => 'BINARY', // 1 = sender marked satisfied
                'recipient_status' => 'BINARY', // 1 = recipient marked satisfied
                'status' => 'INTEGER', // See ESCROW_STATUS_* in points_escrow.php
                'content_type' => 'ID_TEXT',
                'content_id' => 'ID_TEXT',
            ]);
            $GLOBALS['SITE_DB']->create_index('escrow', 'original_points_ledger_id', ['original_points_ledger_id']);
            $GLOBALS['SITE_DB']->create_index('escrow', 'sending_member', ['sending_member']);
            $GLOBALS['SITE_DB']->create_index('escrow', 'receiving_member', ['receiving_member']);
            $GLOBALS['SITE_DB']->create_index('escrow', 'date_and_time', ['date_and_time']);
            $GLOBALS['SITE_DB']->create_index('escrow', 'sender_status', ['sender_status']);
            $GLOBALS['SITE_DB']->create_index('escrow', 'recipient_status', ['recipient_status']);
            $GLOBALS['SITE_DB']->create_index('escrow', 'status', ['status']);

            $GLOBALS['SITE_DB']->create_table('escrow_logs', [
                'id' => '*AUTO',
                'escrow_id' => 'AUTO_LINK',
                'date_and_time' => 'TIME',
                'log_type' => 'ID_TEXT', // Will be a language string from points.ini, one of LOG_ESCROW_*
                'member_id' => '?MEMBER',
                'information' => 'LONG_TRANS__COMCODE',
            ]);
            $GLOBALS['SITE_DB']->create_index('escrow_logs', 'escrow_id', ['escrow_id']);
            $GLOBALS['SITE_DB']->create_index('escrow_logs', 'date_and_time', ['date_and_time']);
            $GLOBALS['SITE_DB']->create_index('escrow_logs', 'member_id', ['member_id']);

            add_privilege('POINTS', 'send_points', true);
            add_privilege('POINTS', 'use_points_escrow', true);
            add_privilege('POINTS', 'moderate_points_escrow', false);
            add_privilege('POINTS', 'moderate_points', false);
            add_privilege('POINTS', 'amend_point_transactions', false);

            $GLOBALS['FORUM_DRIVER']->install_create_custom_field('points_balance', 20, /*locked=*/1, /*viewable=*/0, /*settable=*/0, /*required=*/0, '', 'integer');
            $GLOBALS['FORUM_DRIVER']->install_create_custom_field('points_rank', 20, /*locked=*/1, /*viewable=*/0, /*settable=*/0, /*required=*/0, '', 'integer');
        }

        if (($upgrade_from !== null) && ($upgrade_from < 11) && ($upgrade_from >= 9)) { // LEGACY
            $GLOBALS['SITE_DB']->add_table_field('escrow', 'update_date_and_time', 'TIME');
            $GLOBALS['SITE_DB']->add_table_field('escrow', 'content_type', 'ID_TEXT');
            $GLOBALS['SITE_DB']->add_table_field('escrow', 'content_id', 'ID_TEXT');
            $GLOBALS['SITE_DB']->alter_table_field('escrow', 'receiving_member', '?MEMBER');
        }

        if (($upgrade_from !== null) && ($upgrade_from < 12)) { // LEGACY: 11.beta5; account for new is_ranked field and rename to rank points
            // Life-time points changed to rank points
            $GLOBALS['FORUM_DRIVER']->install_edit_custom_field('points_lifetime', 'points_rank', 20, 1, 0, 0, 0, '', 'integer');
            rename_config_option('points_show_personal_stats_points_lifetime', 'points_show_personal_stats_points_rank');

            $GLOBALS['SITE_DB']->add_table_field('points_ledger', 'is_ranked', 'BINARY', 1);

            // By default, debits to the system do not affect rank points (they're usually purchases)
            $GLOBALS['SITE_DB']->query_update('points_ledger', ['is_ranked' => 0], ['receiving_member' => $GLOBALS['FORUM_DRIVER']->get_guest_id()]);

            // ...but charges from warnings do!
            $GLOBALS['SITE_DB']->query_update('points_ledger', ['is_ranked' => 1], ['t_type' => 'warning', 't_subtype' => 'add']);
        }
    }

    /**
     * Find entry-points available within this module.
     *
     * @param  boolean $check_perms Whether to check permissions
     * @param  ?MEMBER $member_id The member to check permissions as (null: current user)
     * @param  boolean $support_crosslinks Whether to allow cross links to other modules (identifiable via a full-page-link rather than a screen-name)
     * @param  boolean $be_deferential Whether to avoid any entry-point (or even return null to disable the page in the Sitemap) if we know another module, or page_group, is going to link to that entry-point. Note that "!" and "browse" entry points are automatically merged with container page nodes (likely called by page-groupings) as appropriate.
     * @return ?array A map of entry points (screen-name=>language-code/string or screen-name=>[language-code/string, icon-theme-image]) (null: disabled)
     */
    public function get_entry_points(bool $check_perms = true, ?int $member_id = null, bool $support_crosslinks = true, bool $be_deferential = false) : ?array
    {
        if (!addon_installed('points')) {
            return null;
        }

        if ((get_forum_type() == 'cns') || (get_forum_type() == 'none')) {
            return [];
        }
        $ret = [
            'browse' => ['MEMBER_POINT_FIND', 'buttons/search'],
        ];
        if (!$check_perms || !is_guest($member_id)) {
            $ret['member'] = ['POINTS', 'menu/social/points'];
        }
        return $ret;
    }

    public $title;
    public $member_id_of;

    /**
     * Module pre-run function. Allows us to know metadata for <head> before we start streaming output.
     *
     * @return ?Tempcode Tempcode indicating some kind of exceptional output (null: none)
     */
    public function pre_run() : ?object
    {
        $error_msg = new Tempcode();
        if (!addon_installed__messaged('points', $error_msg)) {
            return $error_msg;
        }

        $type = get_param_string('type', 'browse');

        require_lang('points');

        if (($type == 'browse') || ($type == '_search')) {
            inject_feed_url('?mode=points&select=', do_lang('POINTS'));
        }

        if ($type == 'browse') {
            $this->member_id_of = db_get_first_id() + 1;
            inject_feed_url('?mode=points&select=' . strval($this->member_id_of), do_lang('POINTS'));

            breadcrumb_set_parents([['_SELF:_SELF:browse', do_lang_tempcode('MEMBER_POINT_FIND')]]);

            $this->title = get_screen_title('MEMBER_POINT_FIND');
        }

        if ($type == '_search') {
            breadcrumb_set_parents([['_SELF:_SELF:browse', do_lang_tempcode('MEMBER_POINT_FIND')]]);

            $this->title = get_screen_title('MEMBER_POINT_FIND');

            breadcrumb_set_self(do_lang_tempcode('RESULTS'));
        }

        if ($type == 'transact') {
            $member_id_of = get_param_integer('id');

            breadcrumb_set_parents([['_SELF:_SELF:browse', do_lang_tempcode('MEMBER_POINT_FIND')], ['_SELF:_SELF:member:' . strval($member_id_of), do_lang_tempcode('_POINTS', escape_html($GLOBALS['FORUM_DRIVER']->get_username($member_id_of, true)))]]);

            $this->title = get_screen_title('POINTS');
        }

        if (($type == 'escrow') || ($type == 'do_escrow')) {
            $member_id_of = get_param_integer('id');
            $username = $GLOBALS['FORUM_DRIVER']->get_username($member_id_of, true, USERNAME_DEFAULT_ERROR | USERNAME_GUEST_AS_DEFAULT);

            breadcrumb_set_parents([['_SELF:_SELF:browse', do_lang_tempcode('MEMBER_POINT_FIND')], ['_SELF:_SELF:member:' . strval($member_id_of), do_lang_tempcode('_POINTS', escape_html($GLOBALS['FORUM_DRIVER']->get_username($member_id_of, true)))]]);

            $this->title = get_screen_title('_ESCROW_TO', true, [$username]);
        }

        if (($type == 'view_escrow') || ($type == 'satisfy_escrow') || ($type == 'dispute_escrow') || ($type == 'moderate_escrow')) {
            $id = get_param_integer('id');
            $member_id_of = get_param_integer('member_id_of', null);

            // Set title
            if ($type == 'satisfy_escrow') {
                $this->title = get_screen_title('_ESCROW_SATISFY', true, [escape_html(strval($id))]);
            } elseif ($type == 'dispute_escrow') {
                $this->title = get_screen_title('_ESCROW_DISPUTE', true, [escape_html(strval($id))]);
            } elseif ($type == 'moderate_escrow') {
                $this->title = get_screen_title('_ESCROW_MODERATE', true, [escape_html(strval($id))]);
            } else {
                $this->title = get_screen_title('ESCROW_VIEW');
            }

            // Set breadcrumbs
            if ($member_id_of === null) {
                $member_id_viewing = get_member();
                $_row = $GLOBALS['SITE_DB']->query_select('escrow', ['id', 'sending_member', 'receiving_member'], ['id' => $id], '', 1);
                if (($_row === null) || !array_key_exists(0, $_row)) {
                    warn_exit(do_lang_tempcode('MISSING_RESOURCE'));
                }
                $row = $_row[0];

                if ($member_id_viewing === $row['receiving_member']) {
                    $member_id_of = $row['receiving_member'];
                } else {
                    $member_id_of = $row['sending_member'];
                }
            }
            $map = [['_SELF:_SELF:browse', do_lang_tempcode('MEMBER_POINT_FIND')], ['_SELF:_SELF:member:' . strval($member_id_of), do_lang_tempcode('_POINTS', escape_html($GLOBALS['FORUM_DRIVER']->get_username($member_id_of, true)))]];
            if ($type != 'view_escrow') {
                $map[] = ['_SELF:_SELF:view_escrow:' . strval($id), do_lang_tempcode('_ESCROW_VIEW', escape_html(strval($id)))];
            }
            breadcrumb_set_parents($map);
        }

        if ($type == 'member') {
            $this->member_id_of = get_param_integer('id', get_member());
            inject_feed_url('?mode=points&select=' . strval($this->member_id_of), do_lang('POINTS'));

            $username = $GLOBALS['FORUM_DRIVER']->get_username($this->member_id_of, true, USERNAME_DEFAULT_ERROR | USERNAME_GUEST_AS_DEFAULT);
            $this->title = get_screen_title('_POINTS', true, [escape_html($username)]);
        }

        if ($type == 'reverse') {
            $member_id_of = get_param_integer('member_id_of');

            breadcrumb_set_parents([['_SELF:_SELF:browse', do_lang_tempcode('MEMBER_POINT_FIND')], ['_SELF:_SELF:member:' . strval($member_id_of), do_lang_tempcode('_POINTS', escape_html($GLOBALS['FORUM_DRIVER']->get_username($member_id_of, true)))]]);

            $this->title = get_screen_title('REVERSE_TRANSACTION');
        }

        if ($type == 'amend') {
            $member_id_of = get_param_integer('member_id_of');

            breadcrumb_set_parents([['_SELF:_SELF:browse', do_lang_tempcode('MEMBER_POINT_FIND')], ['_SELF:_SELF:member:' . strval($member_id_of), do_lang_tempcode('_POINTS', escape_html($GLOBALS['FORUM_DRIVER']->get_username($member_id_of, true)))]]);

            $this->title = get_screen_title('AMEND_REASON');
        }

        return null;
    }

    /**
     * Execute the module.
     *
     * @return Tempcode The result of execution
     */
    public function run() : object
    {
        if (get_forum_type() == 'none') {
            warn_exit(do_lang_tempcode('NO_FORUM_INSTALLED'));
        }

        require_code('points');
        require_css('points');

        // Work out what we're doing here
        $type = get_param_string('type', 'browse');

        if ($type == 'browse') {
            return $this->points_search_form();
        }
        if ($type == '_search') {
            return $this->points_search_results();
        }
        if ($type == 'transact') {
            return $this->do_transact();
        }
        if ($type == 'escrow') {
            return $this->escrow();
        }
        if ($type == 'do_escrow') {
            return $this->do_escrow();
        }
        if ($type == 'view_escrow') {
            return $this->view_escrow();
        }
        if ($type == 'satisfy_escrow') {
            return $this->satisfy_escrow();
        }
        if ($type == 'dispute_escrow') {
            return $this->dispute_escrow();
        }
        if ($type == 'moderate_escrow') {
            return $this->moderate_escrow();
        }
        if ($type == 'member') {
            return $this->points_profile();
        }
        if ($type == 'reverse') {
            return $this->reverse();
        }
        if ($type == 'amend') {
            return $this->amend();
        }
        if ($type == 'export') {
            return $this->export();
        }

        return new Tempcode();
    }

    /**
     * The UI to search for a member (with regard to viewing their point profile).
     *
     * @return Tempcode The UI
     */
    public function points_search_form() : object
    {
        $post_url = build_url(['page' => '_SELF', 'type' => '_search'], '_SELF', [], false, true);
        require_code('form_templates');
        if (!is_guest()) {
            $username = $GLOBALS['FORUM_DRIVER']->get_username(get_member());
        } else {
            $username = '';
        }
        $fields = form_input_username(do_lang_tempcode('USERNAME'), '', 'username', $username, true, false);
        $submit_name = do_lang_tempcode('SEARCH');
        $text = new Tempcode();
        $text->attach(paragraph(do_lang_tempcode('POINTS_SEARCH_FORM')));
        $text->attach(paragraph(do_lang_tempcode('WILDCARD')));

        return do_template('FORM_SCREEN', [
            '_GUID' => 'e5ab8d5d599093d1a550cb3b3e56d2bf',
            'GET' => true,
            'SKIP_WEBSTANDARDS' => true,
            'HIDDEN' => '',
            'TITLE' => $this->title,
            'URL' => $post_url,
            'FIELDS' => $fields,
            'SUBMIT_ICON' => 'buttons/search',
            'SUBMIT_NAME' => $submit_name,
            'TEXT' => $text,
        ]);
    }

    /**
     * The actualiser for a points profile search.
     *
     * @return Tempcode The UI
     */
    public function points_search_results() : object
    {
        $username = str_replace('*', '%', get_param_string('username'));
        if ((substr($username, 0, 1) == '%') && ($GLOBALS['FORUM_DRIVER']->get_num_members() > 3000)) {
            warn_exit(do_lang_tempcode('CANNOT_WILDCARD_START'));
        }
        if ((strpos($username, '%') !== false) && (strpos($username, '%') < 6) && ($GLOBALS['FORUM_DRIVER']->get_num_members() > 30000)) {
            warn_exit(do_lang_tempcode('CANNOT_WILDCARD_START'));
        }
        if ((strpos($username, '%') !== false) && (strpos($username, '%') < 12) && ($GLOBALS['FORUM_DRIVER']->get_num_members() > 300000)) {
            warn_exit(do_lang_tempcode('CANNOT_WILDCARD_START'));
        }
        $rows = $GLOBALS['FORUM_DRIVER']->get_matching_members($username, 100);
        if (!array_key_exists(0, $rows)) {
            return warn_screen($this->title, do_lang_tempcode('NO_RESULTS'));
        }

        $results = new Tempcode();
        foreach ($rows as $myrow) {
            $id = $GLOBALS['FORUM_DRIVER']->mrow_member_id($myrow);
            if (!is_guest($id)) {
                $url = points_url($id);
                $username = $GLOBALS['FORUM_DRIVER']->mrow_username($myrow);

                $results->attach(do_template('POINTS_SEARCH_RESULT', ['_GUID' => 'df240255b2981dcaee38e126622be388', 'URL' => $url, 'ID' => strval($id), 'USERNAME' => $username]));
            }
        }

        return do_template('POINTS_SEARCH_SCREEN', ['_GUID' => '659af8a012d459db09dad0325a75ac70', 'TITLE' => $this->title, 'RESULTS' => $results]);
    }

    /**
     * The UI for a points profile.
     *
     * @return Tempcode The UI
     */
    public function points_profile() : object
    {
        $member_id_of = $this->member_id_of;

        if (get_forum_type() == 'cns') {
            $url = $GLOBALS['FORUM_DRIVER']->member_profile_url($member_id_of, true);
            if (is_object($url)) {
                $url = $url->evaluate();
            }
            return redirect_screen($this->title, $url . '#tab--points');
        }

        require_code('points3');
        $content = points_profile($member_id_of, get_member());

        return do_template('POINTS_SCREEN', ['_GUID' => '7fadfc2886ba063008f6333fb3f19e75', 'TITLE' => $this->title, 'CONTENT' => $content]);
    }

    /**
     * The actualiser to transact points.
     *
     * @return Tempcode The UI
     */
    public function do_transact() : object
    {
        $member_id_of = get_param_integer('id');

        $trans_type = post_param_string('trans_type', 'send');

        $amount = post_param_integer('amount');
        $reason = post_param_string('reason');

        $worked = false;
        $message = do_lang_tempcode('SUCCESS');

        $member_id_viewing = get_member();
        if (is_guest($member_id_viewing)) { // No cheating
            $message = do_lang_tempcode('MUST_LOGIN');
        } else {
            if ($trans_type == 'send') {
                $anonymous = post_param_integer('anonymous', 0);

                // Force non-anonymity if anonymous transactions are not allowed
                if (get_option('enable_anonymous_transactions') == '0') {
                    $anonymous = 0;
                }

                $viewer_points_balance = points_balance($member_id_viewing);
                $viewer_gift_points_balance = gift_points_balance($member_id_viewing);

                // Check that the member has permission to send points
                if (!has_privilege($member_id_viewing, 'send_points') && !has_privilege($member_id_viewing, 'moderate_points')) {
                    access_denied('PRIVILEGE', 'send_points');
                } elseif (($member_id_of == $member_id_viewing) && (!has_privilege($member_id_viewing, 'send_points_to_self'))) { // No cheating
                    // Handle if the member is trying to send points to themselves
                    $message = do_lang_tempcode('PE_SELF');
                } elseif ($amount < 0) {
                    // Handle trying to send negative points
                    $message = do_lang_tempcode('PE_NEGATIVE_POINTS');
                } elseif ($reason == '') {
                    // Handle if reason is not specified
                    $message = do_lang_tempcode('IMPROPERLY_FILLED_IN');
                } elseif ((($viewer_gift_points_balance + $viewer_points_balance) < $amount)) {
                    // Handle if the member does not have enough points
                    $message = do_lang_tempcode('PE_LACKING_POINTS');
                } else {
                    // Confirm / make the transaction
                    require_code('points2');

                    $member_of = $GLOBALS['FORUM_DRIVER']->get_username($member_id_of);
                    $confirm = get_param_integer('confirm', 0);
                    if ($confirm == 0) {
                        if (get_option('enable_gift_points') == '1') {
                            $point_spread = _points_transact_calculate($member_id_viewing, $amount);
                            $preview = do_lang_tempcode('ARE_YOU_SURE_TRANSACTION_GIFT_POINTS', escape_html($member_of), escape_html(integer_format($amount)), [escape_html(integer_format($point_spread[1])), escape_html(integer_format($point_spread[0])), escape_html(get_timezoned_date_time(time(), false))]);
                        } else {
                            $preview = do_lang_tempcode('ARE_YOU_SURE_TRANSACTION_POINTS', escape_html($member_of), escape_html(integer_format($amount)));
                        }
                        return do_template('CONFIRM_SCREEN', [
                            '_GUID' => '22c701f5c1cb36d55dbc09bb6254591e',
                            'TITLE' => $this->title,
                            'PREVIEW' => $preview,
                            'URL' => get_self_url(false, false, ['confirm' => 1]),
                            'FIELDS' => build_keep_post_fields(),
                        ]);
                    }

                    points_transact($member_id_viewing, $member_id_of, $reason, $amount, null, $anonymous, true, 0, 'points', 'send', '');

                    // Randomised gifts but only if gift points system is enabled and the transaction was not anonymous
                    if ($anonymous == 0) {
                        $reward_credit_chance = intval(get_option('reward_credit_chance'));
                        $reward_credit_amount = intval(get_option('reward_credit_amount'));
                        if ((get_option('enable_gift_points') == '1') && (mt_rand(0, 100) < $reward_credit_chance) && (((floatval($reward_credit_chance) / 100.0) * $reward_credit_amount) >= floatval($amount))) {
                            points_credit_member($member_id_viewing, do_lang('_PR_LUCKY'), $reward_credit_amount, 0, false);

                            $message = do_lang_tempcode('PR_LUCKY', escape_html(integer_format($reward_credit_amount)));
                        } else {
                            $message = do_lang_tempcode('PR_NORMAL');
                        }
                    }

                    $worked = true;
                }
            }
            if (($trans_type == 'credit') || (($trans_type == 'credit-notranked'))) {
                if (has_privilege($member_id_viewing, 'moderate_points')) {
                    // Do not allow crediting negative points
                    if ($amount <= 0) {
                        warn_exit(do_lang_tempcode('IMPROPERLY_FILLED_IN'));
                    }
                    $member_of = $GLOBALS['FORUM_DRIVER']->get_username($member_id_of);
                    $confirm = get_param_integer('confirm', 0);
                    if ($confirm == 0) {
                        $member_points_balance = points_balance($member_id_of);
                        $preview = do_lang_tempcode('ARE_YOU_SURE_TRANSACTION_CREDIT', escape_html($member_of), escape_html(integer_format($amount)), [escape_html(integer_format($member_points_balance)), escape_html(integer_format($member_points_balance + $amount)), escape_html(get_timezoned_date_time(time(), false))]);
                        return do_template('CONFIRM_SCREEN', [
                            '_GUID' => 'd47561c4e730c016894ffea26cf40fdf',
                            'TITLE' => $this->title,
                            'PREVIEW' => $preview,
                            'URL' => get_self_url(false, false, ['confirm' => 1]),
                            'FIELDS' => build_keep_post_fields(),
                        ]);
                    }

                    require_code('points2');
                    points_credit_member($member_id_of, $reason, $amount, 0, true, 0, 'points', 'credit', '', null, ($trans_type == 'credit'));
                    $balance = points_balance($member_id_of);

                    $message = do_lang_tempcode('MEMBER_HAS_BEEN_CREDITED', escape_html($member_of), escape_html(integer_format($amount)), escape_html(integer_format($balance)));

                    $worked = true;
                } else {
                    access_denied('I_ERROR');
                }
            }
            if ($trans_type == 'debit') {
                if (has_privilege($member_id_viewing, 'moderate_points')) {
                    // Do not allow debiting negative points
                    if ($amount < 0) {
                        warn_exit(do_lang_tempcode('IMPROPERLY_FILLED_IN'));
                    }
                    $member_of = $GLOBALS['FORUM_DRIVER']->get_username($member_id_of);
                    $confirm = get_param_integer('confirm', 0);
                    if ($confirm == 0) {
                        $member_points_balance = points_balance($member_id_of);
                        $preview = do_lang_tempcode('ARE_YOU_SURE_TRANSACTION_DEBIT', escape_html($member_of), escape_html(integer_format($amount)), [escape_html(integer_format($member_points_balance)), escape_html(integer_format($member_points_balance - $amount)), escape_html(get_timezoned_date_time(time(), false))]);
                        return do_template('CONFIRM_SCREEN', [
                            '_GUID' => '5d8220d71226c59e222f9376af68bd64',
                            'TITLE' => $this->title,
                            'PREVIEW' => $preview,
                            'URL' => get_self_url(false, false, ['confirm' => 1]),
                            'FIELDS' => build_keep_post_fields(),
                        ]);
                    }

                    require_code('points2');
                    points_debit_member($member_id_of, $reason, $amount, 0, 0, true, 0, 'points', 'debit', '');
                    $balance = points_balance($member_id_of);

                    $message = do_lang_tempcode('MEMBER_HAS_BEEN_DEBITED', escape_html($member_of), escape_html(integer_format($amount)), escape_html(integer_format($balance)));

                    $worked = true;
                } else {
                    access_denied('I_ERROR');
                }
            }
        }

        if ($worked) {
            // Show it worked / Refresh
            $url = points_url($member_id_of);
            return redirect_screen($this->title, $url, $message);
        }
        return warn_screen($this->title, $message);
    }

    /**
     * The UI for creating a new points escrow.
     *
     * @return Tempcode The UI
     */
    public function escrow() : object
    {
        $member_id_of = get_param_integer('id');
        $member_id_viewing = get_member();
        $username = $GLOBALS['FORUM_DRIVER']->get_username($member_id_of, true, USERNAME_GUEST_AS_DEFAULT | USERNAME_DEFAULT_ERROR);

        // Check privileges
        if (is_guest($member_id_viewing)) {
            access_denied('NOT_AS_GUEST');
        } elseif (!has_privilege($member_id_viewing, 'use_points_escrow')) {
            access_denied('PRIVILEGE', 'use_points_escrow');
        } elseif (is_guest($member_id_of)) {
            return warn_screen($this->title, do_lang_tempcode('ESCROW_NO_GUEST'));
        } elseif ($member_id_of == $member_id_viewing) {
            return warn_screen($this->title, do_lang_tempcode('ESCROW_NO_SELF'));
        } elseif (!has_privilege($member_id_of, 'use_points')) {
            return warn_screen($this->title, do_lang_tempcode('ESCROW_MEMBER_CANNOT_USE_POINTS', escape_html($username)));
        }

        require_code('form_templates');

        $fields = new Tempcode();
        $fields->attach(form_input_integer(do_lang_tempcode('POINTS'), do_lang_tempcode('DESCRIPTION_ESCROW_POINTS'), 'escrow_amount', null, true));
        $fields->attach(form_input_line_comcode(do_lang_tempcode('REASON'), do_lang_tempcode('DESCRIPTION_ESCROW_REASON'), 'escrow_reason', '', true, null));
        $fields->attach(form_input_text_comcode(do_lang_tempcode('ESCROW_AGREEMENT'), do_lang_tempcode('DESCRIPTION_ESCROW_AGREEMENT'), 'escrow_agreement', '', true));
        $fields->attach(form_input_date(do_lang_tempcode('EXPIRY_DATE'), do_lang_tempcode('DESCRIPTION_ESCROW_EXPIRY_TIME', escape_html($username)), 'escrow_expiry_time', false, true, true));

        $escrow_url = build_url(['page' => '_SELF', 'type' => 'do_escrow', 'id' => $member_id_of], '_SELF');

        return do_template('FORM_SCREEN', [
            '_GUID' => 'a8404b1fb2be0c2210a37498b381c4f7',
            'HIDDEN' => new Tempcode(),
            'TITLE' => $this->title,
            'FIELDS' => $fields,
            'TEXT' => do_lang_tempcode('DESCRIPTION_ESCROW', escape_html($username)),
            'SUBMIT_ICON' => 'buttons/proceed',
            'SUBMIT_NAME' => do_lang_tempcode('PROCEED'),
            'URL' => $escrow_url,
            'JS_FUNCTION_CALLS' => [],
        ]);
    }

    /**
     * The actualiser for escrowing points to a member.
     *
     * @return Tempcode The UI
     */
    public function do_escrow() : object
    {
        require_code('points2');

        $member_id_of = get_param_integer('id');
        $member_id_viewing = get_member();

        $amount = post_param_integer('escrow_amount');
        $reason = post_param_string('escrow_reason');
        $expiry_time = post_param_date('escrow_expiry_time');
        $agreement = post_param_string('escrow_agreement');

        $viewer_gift_points_balance = gift_points_balance($member_id_viewing);
        $viewer_points_balance = points_balance($member_id_viewing);
        $viewer_balance = ($viewer_gift_points_balance + $viewer_points_balance);

        // Make sure this is a valid transaction
        if (is_guest($member_id_viewing)) {
            access_denied('NOT_AS_GUEST');
        } elseif (!has_privilege($member_id_viewing, 'use_points_escrow')) {
            access_denied('PRIVILEGE', 'use_points_escrow');
        } elseif (is_guest($member_id_of)) {
            return warn_screen($this->title, do_lang_tempcode('ESCROW_NO_GUEST'));
        } elseif ($member_id_of == $member_id_viewing) {
            return warn_screen($this->title, do_lang_tempcode('ESCROW_NO_SELF'));
        } elseif (!has_privilege($member_id_of, 'use_points')) {
            $username = $GLOBALS['FORUM_DRIVER']->get_username($member_id_of, true, USERNAME_GUEST_AS_DEFAULT | USERNAME_DEFAULT_ERROR);
            return warn_screen($this->title, do_lang_tempcode('ESCROW_MEMBER_CANNOT_USE_POINTS', escape_html($username)));
        } elseif (($amount < 0) || ($reason == '') || (($expiry_time !== null) && ($expiry_time < time()))) {
            return warn_screen($this->title, do_lang_tempcode('IMPROPERLY_FILLED_IN'));
        } elseif ($viewer_balance < $amount) {
            if (get_option('enable_gift_points') == '1') {
                return warn_screen($this->title, do_lang_tempcode('ESCROW_NOT_ENOUGH_GIFT_POINTS', escape_html(integer_format($amount)), escape_html(integer_format($viewer_gift_points_balance)), escape_html(integer_format($viewer_points_balance))));
            } else {
                return warn_screen($this->title, do_lang_tempcode('ESCROW_NOT_ENOUGH_POINTS', escape_html(integer_format($amount)), escape_html(integer_format($viewer_points_balance))));
            }
        }

        // Confirmation screen
        $member_of = $GLOBALS['FORUM_DRIVER']->get_username($member_id_of);
        $confirm = get_param_integer('confirm', 0);
        if ($confirm == 0) {
            if (get_option('enable_gift_points') == '1') {
                $point_spread = _points_transact_calculate($member_id_viewing, $amount);
                $preview = do_lang_tempcode('ARE_YOU_SURE_ESCROW_GIFT_POINTS', escape_html($member_of), escape_html(integer_format($amount)), [escape_html(integer_format($point_spread[1])), escape_html(integer_format($point_spread[0])), escape_html(get_timezoned_date_time(time(), false))]);
            } else {
                $preview = do_lang_tempcode('ARE_YOU_SURE_ESCROW_POINTS', escape_html($member_of), escape_html(integer_format($amount)));
            }
            return do_template('CONFIRM_SCREEN', [
                '_GUID' => '72d92df5bb094e18c1a6e92a89bd73b0',
                'TITLE' => $this->title,
                'PREVIEW' => $preview,
                'URL' => get_self_url(false, false, ['confirm' => 1]),
                'FIELDS' => build_keep_post_fields(),
            ]);
        }

        // Create the escrow
        require_code('points_escrow');
        $id = escrow_points($member_id_viewing, $member_id_of, $amount, $reason, $agreement, $expiry_time);

        if ($id === null) {
            return warn_screen($this->title, do_lang_tempcode('INTERNAL_ERROR'));
        }

        // Show it worked / Refresh
        $url = build_url(['page' => '_SELF', 'type' => 'view_escrow', 'id' => $id], '_SELF');
        return redirect_screen($this->title, $url, do_lang_tempcode('SUCCESS'));
    }

    /**
     * The user interface for viewing and managing an escrow.
     *
     * @return Tempcode The UI
     */
    public function view_escrow() : object
    {
        require_code('points_escrow');

        $member_id_viewing = get_member();

        // No guests allowed
        if (is_guest($member_id_viewing)) {
            access_denied('NOT_AS_GUEST');
        }

        $id = get_param_integer('id');

        $_row = $GLOBALS['SITE_DB']->query_select('escrow', ['*'], ['id' => $id], '', 1);
        if (($_row === null) || !array_key_exists(0, $_row)) {
            warn_exit(do_lang_tempcode('MISSING_RESOURCE'));
        }
        $row = $_row[0];

        // Are we trying to access an escrow we do not have the privilege to access?
        if (($row['sending_member'] != $member_id_viewing) && (($row['receiving_member'] === null) || ($row['receiving_member'] != $member_id_viewing)) && !has_privilege($member_id_viewing, 'moderate_points_escrow')) {
            access_denied('PRIVILEGE', 'moderate_points_escrow');
        }

        $reason = get_translated_tempcode('escrow', $row, 'reason');

        $date = get_timezoned_date_time($row['date_and_time']);
        $expiry = ($row['expiration_time'] !== null) ? get_timezoned_date_time($row['expiration_time']) : do_lang_tempcode('NA_EM');
        $from_name = is_guest($row['sending_member']) ? do_lang('SYSTEM') : $GLOBALS['FORUM_DRIVER']->get_username($row['sending_member'], true);
        $_from_name = (is_guest($row['sending_member'])) ? make_string_tempcode(escape_html($from_name)) : hyperlink(points_url($row['sending_member']), escape_html($from_name), false, false, do_lang_tempcode('VIEW_POINTS'));
        $to_name = (($row['receiving_member'] === null) || is_guest($row['receiving_member'])) ? do_lang('SYSTEM') : $GLOBALS['FORUM_DRIVER']->get_username($row['receiving_member'], true);
        $_to_name = (($row['receiving_member'] === null) || is_guest($row['receiving_member'])) ? make_string_tempcode(escape_html($to_name)) : hyperlink(points_url($row['receiving_member']), escape_html($to_name), false, false, do_lang_tempcode('VIEW_POINTS'));

        $buttons = new Tempcode();
        $status = new Tempcode();

        switch ($row['status']) {
            case ESCROW_STATUS_CANCELLED: // In points2.php points_transaction_reverse, reversing the points transaction will also cancel the escrow
                $status = do_lang_tempcode('ESCROW_STATUS__CANCELLED');
                break;
            case ESCROW_STATUS_COMPLETED:
                $status = do_lang_tempcode('ESCROW_STATUS__COMPLETED');
                break;
            default: // ESCROW_STATUS_PENDING and ESCROW_STATUS_DISPUTED
                $involved = ($member_id_viewing == $row['sending_member']) || ($member_id_viewing === $row['receiving_member']);

                // Dispute button
                if ($involved) {
                    $hidden = new Tempcode();
                    $escrow_url = build_url(['page' => 'points', 'type' => 'dispute_escrow', 'id' => $row['id']]);
                    $buttons->attach(do_template('BUTTON_SCREEN', ['_GUID' => '76a89f1d22849a9215c47c58000ad036', 'IMMEDIATE' => true, 'URL' => $escrow_url, 'TITLE' => do_lang_tempcode('ESCROW_DISPUTE'), 'IMG' => 'buttons/report', 'HIDDEN' => $hidden]));
                }

                // Satisfy escrow buttons; not shown / allowed if an escrow is disputed or receiving_member is null (implying content link)
                if (($row['status'] == ESCROW_STATUS_PENDING) && ($row['receiving_member'] !== null) && ((($member_id_viewing == $row['sending_member']) && ($row['sender_status'] == 0)) || (($member_id_viewing == $row['receiving_member']) && ($row['recipient_status'] == 0)))) {
                    $hidden = new Tempcode();
                    $escrow_url = build_url(['page' => 'points', 'type' => 'satisfy_escrow', 'id' => $row['id']]);
                    $buttons->attach(do_template('BUTTON_SCREEN', ['_GUID' => '4090ee4d36dd4006ef9e4ba5262fe786', 'IMMEDIATE' => true, 'URL' => $escrow_url, 'TITLE' => do_lang_tempcode('ESCROW_SATISFIED'), 'IMG' => 'buttons/yes', 'HIDDEN' => $hidden]));
                }

                // Determine actual status
                if ($row['status'] == ESCROW_STATUS_DISPUTED) {
                    $status = do_lang_tempcode('ESCROW_STATUS__DISPUTED');
                } elseif (($row['sender_status'] == 0) && ($row['recipient_status'] == 0)) {
                    $status = do_lang_tempcode('ESCROW_STATUS__PENDING', do_lang('_ESCROW_STATUS__PENDING_BOTH_L'));
                } elseif (($row['sender_status'] == 1) && ($row['recipient_status'] == 0)) {
                    $status = do_lang_tempcode('ESCROW_STATUS__PENDING', escape_html($to_name));
                } elseif (($row['sender_status'] == 0) && ($row['recipient_status'] == 1)) {
                    $status = do_lang_tempcode('ESCROW_STATUS__PENDING', escape_html($from_name));
                }

                // Moderation button
                if ((!$involved) && ($row['status'] >= ESCROW_STATUS_PENDING) && (has_privilege($member_id_viewing, 'moderate_points_escrow'))) {
                    $hidden = new Tempcode();
                    $resolve_url = build_url(['page' => 'points', 'type' => 'moderate_escrow', 'id' => $row['id']]);
                    $buttons->attach(do_template('BUTTON_SCREEN', ['_GUID' => '2c872d12bb8beab7f64a0881f89acaa2', 'IMMEDIATE' => true, 'URL' => $resolve_url, 'TITLE' => do_lang_tempcode('ESCROW_MODERATE'), 'IMG' => 'buttons/advanced', 'HIDDEN' => $hidden]));
                }
        }

        require_code('templates_map_table');

        // Modified map_table_screen since we also have escrow logs on the same page
        $fields = [
            'IDENTIFIER' => strval($row['id']),
            'DATE' => $date,
            'STATUS' => $status,
            'POINTS' => integer_format($row['amount']),
            'FROM' => $_from_name,
            'TO' => $_to_name,
            'REASON' => $reason,
            'ESCROW_AGREEMENT' => get_translated_tempcode('escrow', $row, 'agreement'),
            'EXPIRY_DATE' => $expiry,
        ];
        $_fields = new Tempcode();
        foreach ($fields as $key => $val) {
            $_fields->attach(map_table_field(do_lang_tempcode($key), $val, true));
        }

        return do_template('POINTS_ESCROW_SCREEN', [
            '_GUID' => '7f960458fb37ff9a374e364df664d863',
            'TITLE' => get_screen_title('_ESCROW_VIEW', true, [strval($row['id'])]),
            'FIELDS' => $_fields,
            'BUTTONS' => $buttons,
            'ESCROW_LOGS' => escrow_get_logs($id)
        ]);
    }

    /**
     * The UI / actualiser for satisfying an escrow.
     *
     * @return Tempcode The UI
     */
    public function satisfy_escrow() : object
    {
        require_code('points_escrow');

        $member_id_viewing = get_member();

        // No guests allowed
        if (is_guest($member_id_viewing)) {
            access_denied('NOT_AS_GUEST');
        }

        $id = get_param_integer('id');
        $confirm = get_param_integer('confirm', 0);

        $_row = $GLOBALS['SITE_DB']->query_select('escrow', ['*'], ['id' => $id], '', 1);
        if (($_row === null) || !array_key_exists(0, $_row)) {
            warn_exit(do_lang_tempcode('MISSING_RESOURCE'));
        }
        $row = $_row[0];

        // Are we trying to satisfy an escrow we do not have the privilege to access?
        if (($row['receiving_member'] === null) || (($row['sending_member'] != $member_id_viewing) && ($row['receiving_member'] != $member_id_viewing))) {
            access_denied('I_ERROR');
        }

        // Escrow already completed
        if ($row['status'] < ESCROW_STATUS_PENDING) {
            return warn_screen($this->title, do_lang_tempcode('E_ESCROW_ALREADY_DONE'));
        }

        // Member already marked escrow as satisfied
        if ((($member_id_viewing == $row['sending_member']) && ($row['sender_status'] == 1)) || (($member_id_viewing === $row['receiving_member']) && ($row['recipient_status'] == 1))) {
            return warn_screen($this->title, do_lang_tempcode('E_ESCROW_ALREADY_SATISFIED'));
        }

        $escrow = get_translated_tempcode('escrow', $row, 'reason');

        // Confirmation screen
        if ($confirm == 0) {
            if ($member_id_viewing === $row['sending_member']) {
                $other_member = $GLOBALS['FORUM_DRIVER']->get_username($row['receiving_member'], true);
            } else {
                $other_member = $GLOBALS['FORUM_DRIVER']->get_username($row['sending_member'], true);
            }
            $preview = do_lang_tempcode('ARE_YOU_SURE_ESCROW_SATISFY', escape_html($other_member), $escrow);
            return do_template('CONFIRM_SCREEN', [
                '_GUID' => 'fcfc7796a32b475d9d19a370a1382b63',
                'TITLE' => $this->title,
                'PREVIEW' => $preview,
                'URL' => get_self_url(false, false, ['confirm' => 1]),
                'FIELDS' => build_keep_post_fields(),
            ]);
        }

        satisfy_escrow($id, $member_id_viewing, $row);

        // Show it worked / Refresh
        $url = build_url(['page' => '_SELF', 'type' => 'view_escrow', 'id' => $id], '_SELF');
        return redirect_screen($this->title, $url, do_lang_tempcode('SUCCESS'));
    }

    /**
     * The UI / actualiser for disputing an escrow.
     *
     * @return Tempcode The UI
     */
    public function dispute_escrow() : object
    {
        require_code('points_escrow');

        $member_id_viewing = get_member();

        // No guests allowed
        if (is_guest($member_id_viewing)) {
            access_denied('NOT_AS_GUEST');
        }

        $id = get_param_integer('id');
        $reason = post_param_string('reason', null);

        $_row = $GLOBALS['SITE_DB']->query_select('escrow', ['*'], ['id' => $id], '', 1);
        if (($_row === null) || !array_key_exists(0, $_row)) {
            warn_exit(do_lang_tempcode('MISSING_RESOURCE'));
        }
        $row = $_row[0];

        // Are we trying to access an escrow we do not have the privilege to access?
        if (($row['sending_member'] != $member_id_viewing) && ($row['receiving_member'] !== $member_id_viewing)) {
            access_denied('I_ERROR');
        }

        // Escrow already completed
        if ($row['status'] < ESCROW_STATUS_PENDING) {
            return warn_screen($this->title, do_lang_tempcode('E_ESCROW_ALREADY_DONE'));
        }

        if ($reason === null) {
            // Dispute reason form
            require_code('form_templates');

            $fields = new Tempcode();
            $fields->attach(form_input_text_comcode(do_lang_tempcode('ESCROW_DISPUTE_REASON'), do_lang_tempcode('DESCRIPTION_ESCROW_DISPUTE_REASON'), 'reason', '', true));

            $url = build_url(['page' => '_SELF', 'type' => 'dispute_escrow', 'id' => $id], '_SELF');

            return do_template('FORM_SCREEN', [
                '_GUID' => '7c7068dcdf6aa36c0ef83c61f24972a2',
                'HIDDEN' => new Tempcode(),
                'TITLE' => $this->title,
                'FIELDS' => $fields,
                'TEXT' => do_lang_tempcode('DESCRIPTION_ESCROW_DISPUTE_REASON'),
                'SUBMIT_ICON' => 'buttons/proceed',
                'SUBMIT_NAME' => do_lang_tempcode('PROCEED'),
                'URL' => $url,
                'JS_FUNCTION_CALLS' => [],
            ]);
        }

        dispute_escrow($id, $member_id_viewing, $reason);

        // Show it worked / Refresh
        $url = build_url(['page' => '_SELF', 'type' => 'view_escrow', 'id' => $id], '_SELF');
        return redirect_screen($this->title, $url, do_lang_tempcode('SUCCESS'));
    }

    /**
     * The UI / actualiser for moderating an escrow.
     *
     * @return Tempcode The UI
     */
    public function moderate_escrow() : object
    {
        require_code('points_escrow');

        $member_id_viewing = get_member();

        // No guests allowed
        if (is_guest($member_id_viewing)) {
            access_denied('NOT_AS_GUEST');
        }

        // Are we trying to moderate when we cannot moderate?
        if (!has_privilege($member_id_viewing, 'moderate_points_escrow')) {
            access_denied('PRIVILEGE', 'moderate_points_escrow');
        }

        $id = get_param_integer('id');
        $action = post_param_string('moderate_action', 'edit');
        $points = post_param_integer('points', 0);
        $mod_reason = post_param_string('mod_reason', null);

        $reason = post_param_string('reason', null);
        $agreement = post_param_string('agreement', null);

        $_row = $GLOBALS['SITE_DB']->query_select('escrow', ['*'], ['id' => $id], '', 1);
        if (($_row === null) || !array_key_exists(0, $_row)) {
            warn_exit(do_lang_tempcode('MISSING_RESOURCE'));
        }
        $row = $_row[0];

        // Are we trying to moderate an escrow in which we are involved?
        if (($row['sending_member'] == $member_id_viewing) || ($row['receiving_member'] === $member_id_viewing)) {
            access_denied('I_ERROR');
        }

        $escrow_reason = get_translated_text($row['reason']);
        $escrow_agreement = get_translated_text($row['agreement']);

        if ($mod_reason === null) {
            // Dispute resolution form
            require_code('form_templates');

            $fields = new Tempcode();

            // Cannot perform an action on an escrow that is already completed / cancelled; edit text only
            if ($row['status'] >= ESCROW_STATUS_PENDING) {
                $entries = new Tempcode();
                $entries->attach(form_input_radio_entry('moderate_action', 'amend', true, do_lang_tempcode('ESCROW_MODERATE_ACTION__AMEND')));
                $entries->attach(form_input_radio_entry('moderate_action', 'complete', false, do_lang_tempcode('ESCROW_MODERATE_ACTION__COMPLETE')));
                $entries->attach(form_input_radio_entry('moderate_action', 'cancel', false, do_lang_tempcode('ESCROW_MODERATE_ACTION__CANCEL')));
                $fields->attach(form_input_radio(do_lang_tempcode('ACTION'), do_lang_tempcode('DESCRIPTION_ESCROW_MODERATE_ACTION'), 'moderate_action', $entries, true));
            }
            $fields->attach(form_input_text_comcode(do_lang_tempcode('EXPLANATION'), do_lang_tempcode('DESCRIPTION_ESCROW_MODERATE_REASON'), 'mod_reason', '', true, null, true, '', null, false, 5));
            $fields->attach(do_template('FORM_SCREEN_FIELD_SPACER', [
                '_GUID' => 'cb4511a58a4c78eb75346a468e6e6fdf',
                'TITLE' => do_lang_tempcode('DETAILS'),
            ]));
            $fields->attach(form_input_integer(do_lang_tempcode('POINTS'), do_lang_tempcode('DESCRIPTION_ESCROW_MODERATE_POINTS'), 'points', $row['amount'], true, null, null, null, true));
            $fields->attach(form_input_line_comcode(do_lang_tempcode('AMEND_REASON'), do_lang_tempcode('DESCRIPTION_ESCROW_MODERATE_AMEND_REASON'), 'reason', $escrow_reason, true));
            $fields->attach(form_input_text_comcode(do_lang_tempcode('AMEND_ESCROW_AGREEMENT'), do_lang_tempcode('DESCRIPTION_ESCROW_MODERATE_ESCROW_AGREEMENT'), 'agreement', $escrow_agreement, true));

            $url = build_url(['page' => '_SELF', 'type' => 'moderate_escrow', 'id' => $id], '_SELF');

            require_javascript('points_escrow');
            return do_template('FORM_SCREEN', [
                '_GUID' => '4160b8a06c60cd52dbdbfb3dba5b6452',
                'HIDDEN' => new Tempcode(),
                'TITLE' => $this->title,
                'FIELDS' => $fields,
                'TEXT' => '',
                'SUBMIT_ICON' => 'buttons/proceed',
                'SUBMIT_NAME' => do_lang_tempcode('PROCEED'),
                'URL' => $url,
                'JS_FUNCTION_CALLS' => ['modulePointsEscrowModerate'],
            ]);
        }

        if (($points === null) || ($points < 0) || ($points > $row['amount']) || ($reason === null) || ($agreement === null)) {
            return warn_screen($this->title, do_lang_tempcode('IMPROPERLY_FILLED_IN'));
        }

        moderate_escrow($id, $member_id_viewing, $action, $reason, $agreement, $points, $mod_reason, $row);

        // Show it worked / Refresh
        $url = build_url(['page' => '_SELF', 'type' => 'view_escrow', 'id' => $id], '_SELF');
        return redirect_screen($this->title, $url, do_lang_tempcode('SUCCESS'));
    }

    /**
     * The UI/actualiser to reverse a point transaction.
     *
     * @return Tempcode The UI
     */
    public function reverse() : object
    {
        if (!has_privilege(get_member(), 'moderate_points')) {
            access_denied('PRIVILEGE', 'moderate_points');
        }

        require_code('points3');

        $id = post_param_integer('id');
        $confirm = get_param_integer('confirm', 0);

        $out = transaction_reverse_screen($id, $confirm, $this->title);

        if ($out === null) {
            // Show it worked / Refresh
            $url = get_param_string('redirect', '', INPUT_FILTER_URL_INTERNAL);
            if ($url == '') {
                $_url = build_url(['page' => '_SELF', 'type' => 'browse'], '_SELF');
                $url = $_url->evaluate();
            }
            return redirect_screen($this->title, $url, do_lang_tempcode('SUCCESS'));
        }

        return $out;
    }

    /**
     * The UI/actualiser to amend a point transaction.
     *
     * @return Tempcode The UI
     */
    public function amend() : object
    {
        if (!has_privilege(get_member(), 'amend_point_transactions')) {
            access_denied('PRIVILEGE', 'amend_point_transactions');
        }

        require_code('points3');

        $id = post_param_integer('id');
        $reason = post_param_string('reason', null);
        $redirect = get_param_string('redirect', '', INPUT_FILTER_URL_INTERNAL);
        $member_id_of = get_param_integer('member_id_of');

        $out = transaction_amend_screen($id, $this->title, $member_id_of, $reason, $redirect);
        if ($out === null) {
            // Show it worked / Refresh
            if ($redirect == '') {
                $_redirect = build_url(['page' => '_SELF', 'type' => 'browse'], '_SELF');
                $redirect = $_redirect->evaluate();
            }
            return redirect_screen($this->title, $redirect, do_lang_tempcode('SUCCESS'));
        }

        return $out;
    }

    /**
     * Export a spreadsheet showing the ledger for a member.
     *
     * @return Tempcode The result of execution
     */
    public function export() : object
    {
        $id = get_param_integer('id');

        require_code('tasks');
        return call_user_func_array__long_task(do_lang('EXPORT_POINTS_LEDGER'), $this->title, 'export_points_ledger', [$id]);
    }
}
