<?php
/* https://github.com/elaskavaia/bga-sharedcode/blob/master/modules/tokens.php
 * This is a generic class to manage game pieces.
 *
 * On DB side this is based on a standard table with the following fields:
 * token_key (string), token_location (string), token_state (int)
 *
 *
 * CREATE TABLE IF NOT EXISTS `token` (
 * `token_key` varchar(32) NOT NULL,
 * `token_location` varchar(32) NOT NULL,
 * `token_state` int(10),
 * PRIMARY KEY (`token_key`)
 * ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
 *
 *
 */
class Tokens extends APP_GameClass {
    var $table;
    var $autoreshuffle = false; // If true, a new deck is automatically formed with a reshuffled discard as soon at is needed
    var $autoreshuffle_trigger = null; // Callback to a method called when an autoreshuffle occurs
    // autoreshuffle_trigger = array( 'obj' => object, 'method' => method_name )
    // If defined, tell the name of the deck and what is the corresponding discard (ex : "mydeck" => "mydiscard")
    var $autoreshuffle_custom = array ();
    private $custom_fields;
    private $g_index;

    function __construct() {
        $this->table = 'token';
        $this->custom_fields = array ();
        $this->g_index = array ();
    }

    // MUST be called before any other method if db is not called 'token'
    function init($table) {
        $this->table = $table;
    }

    // This inserts new records in the database. Generically speaking you should only be calling during setup with some
    // rare exceptions.
    //
    // Tokens are added into location specified, (default is 'deck')
    //
    // Tokens is an array with at least the following fields:
    // array(
    //      array(                              // This is my first token
    //          "key" => <unique key>           // This unique alphanum and underscore key, use {INDEX} to replace with index if 'nbr' > 1, i..e "meeple_{INDEX}_red"
    //          "nbr" => <nbr>                  // Number of tokens with this key, default is 1. If nbr >1 and key does not have {INDEX} it will throw an exception
    //          "location" => <location>        // Optional argument specifies the location, alphanum and underscore
    //          "state" => <state>              // Optional argument specifies integer state, if not specified and $token_state_global is not specified auto-increment is used
    function createTokens($tokens, $location_global, $token_state_global = null) {
        if ($location_global)
            $next_pos = $this->getExtremePosition(true, $location_global) + 1;
        else
            $next_pos = 0;
        $values = array ();
        $keys = array ();
        foreach ( $tokens as $token_info ) {
            if (isset($token_info ['nbr']))
                $n = $token_info ['nbr'];
            else
                $n = 1;
            if (isset($token_info ['nbr_start']))
                $start = $token_info ['nbr_start'];
            else
                $start = 0;
            for ($i = $start; $i < $n + $start; $i ++) {
                if (isset($token_info ['location']))
                    $location = $token_info ['location'];
                else
                    $location = $location_global;
                if (isset($token_info ['state']))
                    $token_state = ( int ) ($token_info ['state']);
                else
                    $token_state = $token_state_global;
                if ($token_state === null) {
                    if ($location == $location_global) {
                        $token_state = $next_pos;
                        $next_pos ++;
                    } else {
                        $token_state = 0;
                    }
                }
                $key = $token_info ['key'];
                if ($key == null)
                    throw new feException("createTokens: key cannot be null");
                $key = $this->varsub($key, array_merge($token_info, array ('INDEX' => $i )), true);
                if ($location == null)
                    throw new feException("createTokens: location cannot be null (set per token location or location_global");
                self::checkLocation($location);
                self::checkKey($key);
                $values [] = "( '$key', '$location', '$token_state' )";
                $keys [] = $key;
            }
        }
        $sql = "INSERT INTO " . $this->table . " (token_key,token_location,token_state)";
        $sql .= " VALUES " . implode(",", $values);
        $this->DbQuery($sql);
        return $keys;
    }

    function createToken($key, $location, $token_state = 0) {
        self::checkLocation($location);
        self::checkState($token_state);
        self::checkKey($key);
        $values = array ();
        $values [] = "( '$key', '$location', '$token_state' )";
        $sql = "INSERT INTO " . $this->table . " (token_key,token_location,token_state)";
        $sql .= " VALUES " . implode(",", $values);
        $this->DbQuery($sql);
    }

    function createTokensPack($key, $location, $nbr = 1, $nbr_start = 1, $iterArr = null, $token_state = null) {
        if ($iterArr == null)
            $iterArr = array ('' );
        if (! is_array($iterArr))
            throw new feException("iterArr must be an array");
        if (count($iterArr) == 0)
            $iterArr = array ('' );
        $tokenSpec = array ('key' => $key,'location' => $location,'nbr' => $nbr,'nbr_start' => $nbr_start );
        $tokens = array ();
        foreach ( $iterArr as $iterKey ) {
            $newspec = array ();
            foreach ( $tokenSpec as $tokenSpecKey => $value ) {
                $value = $this->varsub($value, array ('TYPE' => $iterKey ));
                $newspec [$tokenSpecKey] = $value;
            }
            $tokens [] = $newspec;
        }
        return $this->createTokens($tokens, null, $token_state);
    }

    // Get max on min state on the specific location
    function getExtremePosition($getMax, $location, $token_key = null) {
        self::checkLocation($location, true);
        if ($getMax)
            $sql = "SELECT MAX( token_state ) res ";
        else
            $sql = "SELECT MIN( token_state ) res ";
        $sql .= "FROM " . $this->table;
        
        $like = "LIKE";
        if (strpos($location, "%") === false) {
            $like = "=";
        }
        $sql .= " WHERE token_location $like '$location' ";
        if ($token_key != null) {
            self::checkKey($token_key, true);
            $like = "LIKE";
            if (strpos($token_key, "%") === false) {
                $like = "=";
            }
            $sql .= " AND token_key $like '$token_key' ";
        }
        
        $dbres = self::DbQuery($sql);
        $row = mysql_fetch_assoc($dbres);
        if ($row)
            return $row ['res'];
        else
            return 0;
    }

    // Shuffle token of a specified location, result of the operation will changes state of the token to be a position after shuffling
    function shuffle($location) {
        self::checkLocation($location);
        $token_keys = self::getObjectListFromDB("SELECT token_key FROM " . $this->table . " WHERE token_location='$location'", true);
        shuffle($token_keys);
        $n = 0;
        foreach ( $token_keys as $token_key ) {
            self::DbQuery("UPDATE " . $this->table . " SET token_state='$n' WHERE token_key='$token_key'");
            $n ++;
        }
    }

    // Pick the first "$nbr" cards on top of specified deck and place it in target location
    // Return cards infos or void array if no card in the specified location
    function pickTokensForLocation($nbr, $from_location, $to_location, $state = 0, $no_deck_reform = false) {
        $tokens = self::getTokensOnTop($nbr, $from_location);
        $tokens_ids = array ();
        foreach ( $tokens as $i => $card ) {
            $tokens_ids [] = $card ['key'];
            $tokens [$i] ['location'] = $to_location;
            $tokens [$i] ['state'] = $state;
        }
        $sql = "UPDATE " . $this->table . " SET token_location='" . addslashes($to_location) . "', token_state='$state' ";
        $sql .= "WHERE token_key IN ('" . implode("','", $tokens_ids) . "') ";
        self::DbQuery($sql);
        if (isset($this->autoreshuffle_custom [$from_location]) && count($tokens) < $nbr && $this->autoreshuffle && ! $no_deck_reform) {
            // No more cards in deck & reshuffle is active => form another deck
            $nbr_token_missing = $nbr - count($tokens);
            self::reformDeckFromDiscard($from_location);
            $newcards = self::pickTokensForLocation($nbr_token_missing, $from_location, $to_location, $state, true); // Note: block anothr deck reform
            foreach ( $newcards as $card ) {
                $tokens [] = $card;
            }
        }
        return $tokens;
    }

    /**
     * Return token on top of this location, top defined as item with higher state value
     */
    function getTokenOnTop($location) {
        $result_arr = $this->getTokensOnTop(1, $location);
        if (count($result_arr) > 0)
            return $result_arr [0];
        return null;
    }

    /**
     * Return "$nbr" tokens on top of this location, top defined as item with higher state value
     */
    function getTokensOnTop($nbr, $location) {
        self::checkLocation($location);
        self::checkPosInt($nbr);
        $result = array ();
        $sql = $this->getSelectQuery();
        $sql .= " WHERE token_location='$location'";
        $sql .= " ORDER BY token_state DESC";
        $sql .= " LIMIT $nbr";
        $dbres = self::DbQuery($sql);
        while ( $row = mysql_fetch_assoc($dbres) ) {
            $result [] = $row;
        }
        return $result;
    }

    function reformDeckFromDiscard($from_location) {
        self::checkLocation($from_location);
        if (isset($this->autoreshuffle_custom [$from_location]))
            $discard_location = $this->autoreshuffle_custom [$from_location];
        else
            throw new feException("reformDeckFromDiscard: Unknown discard location for $from_location !");
        self::checkLocation($discard_location);
        self::moveAllTokensInLocation($discard_location, $from_location);
        self::shuffle($from_location);
        if ($this->autoreshuffle_trigger) {
            $obj = $this->autoreshuffle_trigger ['obj'];
            $method = $this->autoreshuffle_trigger ['method'];
            $obj->$method($from_location);
        }
    }

    // Set token state
    function setTokenState($token_key, $state) {
        self::checkState($state);
        self::checkKey($token_key);
        $sql = "UPDATE " . $this->table;
        $sql .= " SET token_state='$state'";
        $sql .= " WHERE token_key='$token_key'";
        self::DbQuery($sql);
        return $state;
    }

    // Move a card to specific location
    function moveToken($token_key, $location, $state = 0) {
        self::checkLocation($location);
        self::checkState($state);
        self::checkKey($token_key);
        $sql = "UPDATE " . $this->table;
        $sql .= " SET token_location='$location', token_state='$state'";
        $sql .= " WHERE token_key='$token_key'";
        self::DbQuery($sql);
    }

    // Move cards to specific location
    function moveTokens($tokens, $location, $state = 0) {
        self::checkLocation($location);
        self::checkState($state);
        self::checkTokenKeyArray($tokens);
        $sql = "UPDATE " . $this->table;
        $sql .= " SET token_location='$location', token_state='$state'";
        $sql .= " WHERE token_key IN ('" . implode("','", $tokens) . "')";
        self::DbQuery($sql);
    }

    // Move a card to a specific location where card are ordered. If location_arg place is already taken, increment
    // all tokens after location_arg in order to insert new card at this precise location
    function insertToken($token_key, $location, $state = 0) {
        self::checkLocation($location);
        self::checkState($state);
        $sql = "UPDATE " . $this->table;
        $sql .= " SET token_state=token_state+1";
        $sql .= " WHERE token_location='$location' ";
        $sql .= " AND token_state>=$state";
        self::DbQuery($sql);
        self::moveToken($token_key, $location, $state);
    }

    function insertTokenOnExtremePosition($token_key, $location, $bOnTop) {
        $extreme_pos = self::getExtremePosition($bOnTop, $location);
        if ($bOnTop)
            self::insertToken($token_key, $location, $extreme_pos + 1);
        else
            self::insertToken($token_key, $location, $extreme_pos - 1);
    }

    // Move all tokens from a location to another
    // !!! state is reset to 0 or specified value !!!
    // if "from_location" and "from_state" are null: move ALL cards to specific location
    function moveAllTokensInLocation($from_location, $to_location, $from_state = null, $to_state = 0) {
        if ($from_location != null)
            self::checkLocation($from_location);
        self::checkLocation($to_location);
        $sql = "UPDATE " . $this->table . " ";
        $sql .= "SET token_location='$to_location', token_state='$to_state' ";
        if ($from_location !== null) {
            $sql .= "WHERE token_location='" . addslashes($from_location) . "' ";
            if ($from_state !== null)
                $sql .= "AND token_state='$from_state' ";
        }
        self::DbQuery($sql);
    }

    /**
     * Move all tokens from a location to another location arg stays with the same value
     */
    function moveAllTokensInLocationKeepOrder($from_location, $to_location) {
        self::checkLocation($from_location);
        self::checkLocation($to_location);
        $sql = "UPDATE " . $this->table;
        $sql .= " SET token_location='$to_location'";
        $sql .= " WHERE token_location='$from_location'";
        self::DbQuery($sql);
    }

    /**
     * Return all tokens in specific location
     * note: if "order by" is used, result object is NOT indexed by ids
     */
    function getTokensInLocation($location, $state = null, $order_by = null) {
        return $this->getTokensOfTypeInLocation(null, $location, $state, $order_by);
    }

    function getTokenOnLocation($location) {
        $res = $this->getTokensOfTypeInLocation(null, $location);
        return array_shift($res);
    }

    /**
     * Get tokens of a specific type in a specific location, since there is no field for type we use like expression on
     * key
     *
     * @param string $type
     * @param string $location
     * @param int $state
     *
     * @return array mixed
     */
    function getTokensOfTypeInLocation($type, $location = null, $state = null, $order_by = null) {
        $sql = $this->getSelectQuery();
        $sql .= " WHERE true ";
        if ($type !== null) {
            if (strpos($type, "%") === false) {
                $type .= "%";
            }
            self::checkType($type);
            $sql .= " AND token_key LIKE '$type'";
        }
        if ($location !== null) {
            self::checkLocation($location, true);
            $like = "LIKE";
            if (strpos($location, "%") === false) {
                $like = "=";
            }
            $sql .= " AND token_location $like '$location' ";
        }
        if ($state !== null) {
            self::checkState($state, true);
            $sql .= " AND token_state = '$state'";
        }
        if ($order_by !== null)
            $sql .= " ORDER BY $order_by";
        $dbres = self::DbQuery($sql);
        $result = array ();
        $i = 0;
        while ( $row = mysql_fetch_assoc($dbres) ) {
            if ($order_by !== null) {
                $result [$i] = $row;
            } else {
                $result [$row ['key']] = $row;
            }
            $i ++;
        }
        return $result;
    }

    function getTokenState($token_id) {
        $res = $this->getTokenInfo($token_id);
        if ($res == null)
            return null;
        return $res ['state'];
    }

    function getTokenLocation($token_id) {
        $res = $this->getTokenInfo($token_id);
        if ($res == null)
            return null;
        return $res ['location'];
    }

    /**
     * Get specific token info
     */
    function getTokenInfo($token_key) {
        self::checkKey($token_key);
        $sql = $this->getSelectQuery();
        $sql .= " WHERE token_key='$token_key' ";
        $dbres = self::DbQuery($sql);
        return mysql_fetch_assoc($dbres);
    }

    /**
     * Get specific tokens info
     */
    function getTokensInfo($tokens_array) {
        self::checkTokenKeyArray($tokens_array);
        if (count($tokens_array) == 0)
            return array ();
        $sql = $this->getSelectQuery();
        $sql .= " WHERE token_key IN ('" . implode("','", $tokens_array) . "') ";
        $dbres = self::DbQuery($sql);
        $result = array ();
        while ( $row = mysql_fetch_assoc($dbres) ) {
            $result [$row ['key']] = $row;
        }
        if (count($result) != count($tokens_array)) {
            self::error("getTokens: some cards have not been found:");
            self::error("requested: " . implode(",", $tokens_array));
            self::error("received: " . implode(",", array_keys($result)));
            throw new feException("getTokens: Some cards have not been found !");
        }
        return $result;
    }

    function countTokensInLocation($location, $state = null) {
        self::checkLocation($location, true);
        self::checkState($state, true);
        $like = "LIKE";
        if (strpos($location, "%") === false) {
            $like = "=";
        }
        $sql = "SELECT COUNT( token_key ) cnt FROM " . $this->table;
        $sql .= " WHERE token_location $like '$location' ";
        if ($state !== null)
            $sql .= "AND token_state='$state' ";
        $dbres = self::DbQuery($sql);
        if ($row = mysql_fetch_assoc($dbres))
            return $row ['cnt'];
        else
            return 0;
    }

    // Return an array "location" => number of cards
    function countTokensInLocations() {
        $result = array ();
        $sql = "SELECT token_location, COUNT( token_key ) cnt FROM " . $this->table . " GROUP BY token_location ";
        $dbres = self::DbQuery($sql);
        while ( $row = mysql_fetch_assoc($dbres) ) {
            $result [$row ['token_location']] = $row ['cnt'];
        }
        return $result;
    }

    // Return an array "state" => number of tokens (for this location)
    function countTokensByState($location) {
        self::checkLocation($location);
        $result = array ();
        $sql = "SELECT token_state, COUNT( token_key ) cnt FROM " . $this->table . " ";
        $sql .= "WHERE token_location='$location' ";
        $sql .= "GROUP BY token_state ";
        $dbres = self::DbQuery($sql);
        while ( $row = mysql_fetch_assoc($dbres) ) {
            $result [$row ['token_state']] = $row ['cnt'];
        }
        return $result;
    }

    function varsub($line, $keymap, $usegindex = false) {
        if ($line === null)
            throw new feException("varsub: line cannot be null");
        if (strpos($line, "{") !== false) {
            foreach ( $keymap as $key => $value ) {
                if (strpos($line, "{$key}") !== false) {
                    $line = preg_replace("/\{$key\}/", $value, $line);
                }
            }
            if ($usegindex)
                foreach ( $this->g_index as $key => $value ) {
                    if (strpos($line, "{$key}") !== false) {
                        $value ++;
                        $line = preg_replace("/\{$key\}/", $value, $line);
                        $this->g_index [$key] = $value;
                    }
                }
        }
        return $line;
    }

    final function checkLocation($location, $like = false) {
        if ($location == null)
            throw new feException("location cannot be null");
        $extra = "";
        if ($like)
            $extra = "%";
        if (preg_match("/^[A-Za-z_0-9${extra}-]+$/", $location) == 0) {
            throw new feException("location must be  alphanum and underscore non empty string");
        }
    }

    final function checkState($state, $canBeNull = false) {
        if ($state === null && $canBeNull == false)
            throw new feException("state cannot be null");
        if ($state !== null && preg_match("/^-*[0-9]+$/", $state) == 0) {
            throw new feException("state must be integer number");
        }
    }

    final function checkTokenKeyArray($arr) {
        if ($arr == null)
            throw new feException("tokens cannot be null");
        if (! is_array($arr))
            throw new feException("tokens must be an array");
        foreach ( $arr as $key ) {
            $this->checkKey($key);
        }
    }

    final function checkKey($key, $like = false) {
        if ($key == null)
            throw new feException("key cannot be null");
        $extra = "";
        if ($like)
            $extra = "%";
        if (preg_match("/^[A-Za-z_0-9${extra}]+$/", $key) == 0) {
            throw new feException("key must be alphanum and underscore non empty string '$key'");
        }
    }

    final function checkType($key) {
        if ($key == null)
            throw new feException("type cannot be null");
        $this->checkKey($key, true);
    }

    final function checkPosInt($key) {
        if ($key && preg_match("/^[0-9]+$/", $key) == 0) {
            throw new feException("must be integer number");
        }
    }

    final function getSelectQuery() {
        $sql = "SELECT token_key AS \"key\", token_location AS \"location\", token_state AS \"state\"";
        if (count($this->custom_fields)) {
            $sql .= ", ";
            $sql .= implode(', ', $this->custom_fields);
        }
        $sql .= " FROM " . $this->table;
        return $sql;
    }

    function setCustomFields($fields_array) {
        $this->checkTokenKeyArray($fields_array);
        $this->custom_fields = $fields_array;
    }

    function initGlobalIndex($key, $value = 1) {
        if (! array_key_exists($key, $this->g_index)) {
            $this->checkKey($key);
            $this->checkPosInt($value);
            $sql = "INSERT INTO " . $this->table . " (token_key,token_location,token_state)";
            $sql .= " VALUES ('$key','$key','$value')";
            $this->DbQuery($sql);
            $this->g_index [$key] = $value;
        } else {
            $this->g_index [$key] = $value;
        }
        return $value;
    }

    private function setGlobalIndex($key, $value) {
        $sql = "UPDATE " . $this->table;
        $sql .= " SET token_state='$value'";
        $sql .= " WHERE token_key='$key'";
        self::DbQuery($sql);
        $this->g_index [$key] = $value;
        return $value;
    }

    function syncGlobalIndex($key) {
        $this->checkKey($key);
        $sql = "SELECT token_state";
        $sql .= " FROM " . $this->table;
        $sql .= " WHERE token_key='$key'";
        $dbres = self::DbQuery($sql);
        $row = mysql_fetch_assoc($dbres);
        if ($row)
            $value = $row ['token_state'];
        else {
            unset($this->g_index [$key]);
            $value = $this->initGlobalIndex($key, 1);
        }
        $this->g_index [$key] = $value;
        return $value;
    }

    function commitGlobalIndex($key) {
        if (! array_key_exists($key, $this->g_index)) {
            throw new feException("global index $key is not defined");
        }
        $this->setGlobalIndex($key, $this->g_index [$key]);
        return $this->g_index [$key];
    }
}