<?php

/**
 *  BindParams -- helper class
 *
 *  this is a helper class for the gacMySQL class, specifically for generating dynamic prepared statements.
 *
 *  this class, when instantiated, creates storage for a prepared statement's type and values.  When we want to
 *  create the prepared statement, we use call_user_func_array() and use the output from this method to generate
 *  the arguments that are normally passed in a prepared statement.
 *
 *  using this class allows a data payload to be dynamically parsed and validated - allows a client to update
 *  a sub-set of a table without having to explicitly enumerate every column in the table.
 *
 *  @author mike@givingassistant.org
 *  @version 1.0
 *
 *  HISTORY:
 *  --------
 *  06-29-17    mks     original coding
 *
 */
class BindParams {
    private $values = array();
    private $types = '';

    /**
     * add() -- public method
     *
     * this method accepts two parameters as input - the type of the variable and the value of the variable.  In
     * this instance, when I say variable, I am referring to a mysql table column.
     *
     * if, for some unknown reason, type is a value not allowed, reset it to type 's' which should cover-up most
     * mistakes.
     *
     * $value as a call-by-reference to suppress a PHP warning message.
     *
     * @author  mike@givingassistant.org
     * @version 1.0
     *
     * @param $type
     * @param $value
     *
     * HISTORY:
     * --------
     * 06-29-17     mks     original coding
     *
     */
    public function add(string $type, &$value)
    {
        switch ($type) {
            case 'd' :
            case 'i' :
            case 'b' :
            case 's' :
                break;
            default  :
                $type = 's';
        }
        $this->values[] = $value;
        $this->types .= $type;
    }

    public function isEmpty()
    {
        return((empty($this->values)) ? true : false);
    }

    public function checkOrd()
    {
        return((count($this->values) == strlen($this->types)));
    }




    /**
     * get() -- public method
     *
     * get() simply returns the two class variables as a string of output that's tailored to the input
     * requirement of mysqli::bind_param().
     *
     * @author  mike@givingassistant.org
     * @version 1.0
     *
     * @return array
     *
     * HISTORY:
     * --------
     * 06-29-17     mks     Original coding
     *
     */
    public function get()
    {
        return array_merge(array($this->types), $this->values);
    }

    public function refValues($arr) {
        $refs = array();
        foreach($arr as $key => $value)
            $refs[$key] = &$arr[$key];
        return $refs;
    }
}


class gacMySQL extends gaaNamasteCore
{
    private   $slaveConnection  = null;                         // resource link to mysqli service
    protected $useSlaveServer   = false;                        // should be overridden in the class instantiation
    protected $batchSize        = PDO_RECORDS_PER_PAGE;
    protected $mySqlTypes       = array();
    protected $tip              = false;                        // indicates if a transaction is already in progress
    protected $uniqueIndexes = null;
    protected $compoundIndexes = null;
    protected $exposedFields = null;
    protected $dbEvent;                                         // used to track the different sql events
    protected $rowsAffected;                                    // how many rows were affected by the sql query
    protected $queryResult;                                     // container to hold return payload from mysqli
    protected $recordLimit;
    protected $serviceReady;                                    // boolean indicating if the mysql service is ready

    // exceptions to the query-builder
    public $queryOrderBy;
    public $queryOrderByDirection;
    public $queryGroupBy;
    public $queryGroupByDirection;
    public $queryLimit;
    public $queryHaving;
    public $mysqlMasterAvailable;
    public $mysqlSlaveAvailable;

    // allowable operands for mysql
    public $operands = [
        OPERATOR_EQ,
        OPERATOR_LTE,
        OPERATOR_GTE,
        OPERATOR_DNE,
        OPERATOR_LT,
        OPERATOR_GT,
        OPERATOR_NE
    ];


    /**
     * __construct -- public method
     *
     * constructor for the mysql data instantiation class
     *
     * Three input parameters are supported for the constructor:
     *
     * $_template:  the name of the template that establishes which data class will be instantiated
     * $_meta:      the meta data payload as received from the broker - critical because it contains the name
     *              of the class template we're going to be instantiating.
     * $_id:        an optional parameter - if provided, we'll instantiate the class and then attempt to load
     *              the record referenced by the primary key value (after evaluating the id type).
     *
     * Next, we going to assign mysql resources - based on the configuration, if we're supporting slave reads, make
     * the appropriate assignments so the correct resource is engaged for any particular query.
     *
     * Load the template properties into the class and set the class properties accordingly.
     *
     * Every mySQL table has two "primary keys" -- the traditional auto-incrementing integer, and a guid string.
     * The best-practices effort of "id's internally, guids externally" applies to mysql structures.
     *
     * When we instantiate the class and we receive an id, we have to evaluate if we're passed a string (guid) or an
     * integer (id) and adjust the current pkey pointer appropriately so that correct query is build deeper down.
     *
     * Therefore, mysql is the first and, of this writing, the only db instantiation class that has a floating pkey
     * value/type which is established on a data fetch at run-time.
     *
     *
     * @author  mike@givingassistant.org
     * @version 1.0
     *
     * @param string $_template
     * @param array $_meta
     * @param mixed $_id
     *
     * HISTORY:
     * --------
     * 06-29-17     mks     initial coding
     *
     */
    public function __construct(string $_template, array $_meta, $_id = null)
    {
        register_shutdown_function(array($this, '__destruct'));

        parent::__construct();

        if ($this->trace and $this->logger->available) {
            $this->logger->trace(STRING_ENT_METH . __METHOD__);
            if (!empty($_guid) and $this->debug) {
                $this->logger->debug('received guid: ' . $_guid);
            }
        }

        // validate the meta data payload
        if (empty($_meta)) {
            $this->state = STATE_META_ERROR;
            $this->logger->data(ERROR_DATA_META_REQUIRED);
            $this->eventMessages[] = ERROR_DATA_META_REQUIRED;
            return;
        } elseif (!array_key_exists(META_TEMPLATE, $_meta)) {
            $this->state = STATE_META_ERROR;
            $msg = ERROR_DATA_META_KEY_404 . META_TEMPLATE;
            $this->logger->data($msg);
            $this->eventMessages[] = $msg;
            return;
        }

        // invoke the parent constructor, load the mysql configuration
        parent::__construct();
        $this->status = false;
        $this->config = gasConfig::$settings[CONFIG_DATABASE_MYSQL];
        if (empty($this->config)) {
            $msg = ERROR_CONFIG_RESOURCE_404 . RESOURCE_MYSQL;
            $this->logger->warn($msg);
            $this->eventMessages[] = $msg;
            $this->state = STATE_RESOURCE_ERROR_MYSQL;
            return;
        }

        // load the template
        $this->templateName = STRING_CLASS_GAT . $_meta[META_TEMPLATE];
        if (!$this->loadTemplate()) {
            $this->logger->warn(ERROR_TEMPLATE_INSTANTIATE . $_meta[META_TEMPLATE]);
            $this->state = STATE_TEMPLATE_ERROR;
            return;
        }
        $this->class = $_meta[META_TEMPLATE];  // set the class to the name of the requested data class

        // if we're passed an optional $_id, then evaluate which type of id we're working with and make
        // the appropriate assignments.
        if (!empty($_id)) {
            $_id = trim($_id);
            $_id = (is_numeric($_id)) ? abs(intval($_id)) : $_id;
            switch (gettype($_id)) {
                case DATA_TYPE_STRING :
                    if (validateGUID($_id)) {
                        if ($this->pKey != PKEY_GUID) {
                            $msg = sprintf(ERROR_PKEY_TYPE, DATA_TYPE_STRING);
                            $this->logger->error($msg);
                            $this->state = STATE_DATA_TYPE_ERROR;
                            $this->eventMessages[] = $msg;
                            return;
                        }
                    } else {
                        $msg = ERROR_INVALID_GUID . $_id;
                        $this->eventMessages[] = $msg;
                        $this->logger->error($msg);
                        $this->state = STATE_DATA_ERROR;
                        return;
                    }
                break;
                case DATA_TYPE_INTEGER :
                    if ($this->pKey != PKEY_ID) {
                        $msg = sprintf(ERROR_PKEY_TYPE, DATA_TYPE_INTEGER);
                        $this->logger->error($msg);
                        $this->eventMessages[] = $msg;
                        $this->state = STATE_DATA_TYPE_ERROR;
                        return;
                    }
                    $this->pKey = PKEY_ID;
                break;
                default :
                    $msg = sprintf(ERROR_PKEY_TYPE, gettype($_id));
                    $this->logger->error($msg);
                    $this->eventMessages[] = $msg;
                    $this->state = STATE_DATA_TYPE_ERROR;
                    return;
                break;
            }
        }

        // establish and assign mysql connections
        if (gasResourceManager::$mySqlMasterAvailable) {
            $this->connection = gasResourceManager::fetchResource(RESOURCE_MYSQL_MASTER);
            $this->mysqlMasterAvailable = true;
            if (gasResourceManager::$mySqlSlaveAvailable) {
                $this->slaveConnection = gasResourceManager::fetchResource(RESOURCE_MYSQL_SLAVE);
                $this->mysqlSlaveAvailable = true;
            } else {
                $this->mysqlSlaveAvailable = false;
            }
        } else {
            $this->mysqlMasterAvailable = false;
            $this->state = STATE_RESOURCE_ERROR_MYSQL;
            return;
        }

        $this->queryOrderBy = null;
        $this->queryGroupBy = null;
        $this->queryLimit = null;
        $this->queryHaving = null;
        $this->queryGroupByDirection = null;
        $this->queryGroupByDirection = null;
        $this->serviceReady = true;
        // if we have an $_id, load the record
        if ($this->collectionName != NONE) {
            $this->buildIndexReference();
            $this->setRowsReturnedLimit();
        }
    }


    /**
     * buildIndexReference() -- private method
     *
     * this method looks at the table defined in the current class instantiation and fetches the schema
     * information about the table from mysql.
     *
     * Each returned array structure from the query looks like this:
     *
     * Array
     *    (
     *       [Field] => email_usr
     *       [Type] => varchar(50)
     *       [Null] => NO
     *       [Key] => UNI
     *       [Default] =>
     *       [Extra] =>
     *    )
     *
     * We're looking for the column 'Key' to be not-empty as this indicates that the table column is indexed
     * in some way.
     *
     * We want to save the indexed column information in a K->V paired array so that, when we're parsing
     * queries submitted to the mysql service, we can screen the query and prevent the execution of any
     * query that does not use the indexed columns of the table.
     *
     * The K->V associative array will be stored locally in the the $fieldTypes variable (declared in the core).
     *
     * The Key will contain the name of the indexed column, and the Value will have the mysql type definition
     * for that column.
     *
     * If the query execution generates a mysql error, set a WARN message and return.
     * If the query executes, but no indexed columns are returned, raise a WARN message.
     *
     *
     * @author  mike@givingassistant.org
     * @version 1.0
     *
     * HISTORY:
     * --------
     * 06-30-17     mks     original coding
     *
     */
    private function buildIndexReference()
    {
        if ($this->trace) $this->logger->trace(STRING_ENT_METH . __METHOD__);

        $data = null;
        // generate the cache key appropriate to the class and see if we've already cached this info
        // based off a previous instantiation...
        $cKey = CACHE_NAMASTE_KEY . '_' . CACHE_MYSQL_TABLE_SCHEMA . '_' . $this->collectionName;
        if ($data = gasCache::get($cKey)) {
            $data = json_decode(gzuncompress($data), true);
        } else {
            $this->dbEvent = DB_EVENT_NAMASTE;
            $this->strQuery = 'SHOW COLUMNS FROM ' . $this->collectionName;
            $this->executeNonPreparedQuery();
            if (!$this->rowsAffected) {
                $this->logger->warn(ERROR_SQL_FTL_INDEXES);
                $this->eventMessages[] = ERROR_SQL_FTL_INDEXES;
            } else {
                foreach($this->queryResults as $row) {
                    $data[] = $row;
                }
            }
            gasCache::add($cKey, gzcompress(json_encode($data, true)));
        }
        if (!empty($data) and is_array($data)) {
            foreach ($data as $row) {
                @$this->mySqlTypes[$row[MYSQL_COLUMN_FIELD]] = $row[MYSQL_COLUMN_TYPE];
                if (!empty($row[MYSQL_COLUMN_KEY])) {
                    $this->indexes[] = $row[MYSQL_COLUMN_FIELD];
                }
                if (@$row[MYSQL_COLUMN_KEY] == MYSQL_INDEX_PRIMARY or @$row[MYSQL_COLUMN_KEY] == MYSQL_INDEX_UNIQUE) {
                    $this->uniqueIndexes[] = $row[MYSQL_COLUMN_FIELD];
                }
            }
        }
    }


    /**
     * setRowsReturnedLimit() -- private method
     *
     * this function is called in the constructor for the current table instantiation.
     *
     * it looks at the information_schema table to get the average_row_length (arl) value for the table.
     * this is a gross calculation - the more data in the table, the more accurate the value.
     *
     * if we can get the arl value from the information_schema, then divide this number into the system
     * constant (max_data_returned) to see if the result is less-than or equal-to the system constant for the
     * number of rows returned per query...
     *
     * if the calculated value is smaller, then allow the system constants to remain -- if not, adjust the system
     * constant for the max_rows_returned so that the total amount of data remains under the system constant
     * max_data_returned.
     *
     * @author  mike@givingassistant.org
     * @version 1.0
     *
     * HISTORY:
     * 07-10-17     mks     original coding
     *
     */
    private function setRowsReturnedLimit()
    {
        if (gasConfig::$settings[ERROR_TRACE] and $this->logger->available) {
            $this->logger->trace(STRING_ENT_METH . __METHOD__);
        }
        $key = PDO_DATA_DEFINITION . '_' . PDO_AVG_ROW_LEN . '_' . $this->collectionName;
        $cacheData = null;
        $this->dbEvent = MYSQL_EVENT_META;

        if ($cacheData = gasCache::get($key)) {
            $cacheData = json_decode(gzuncompress($cacheData), true);
            $this->recordLimit = $cacheData[PDO_RECORDS_PER_PAGE];
        } else {
            $schema = gasConfig::$settings[CONFIG_DATABASE][CONFIG_DATABASE_MYSQL][CONFIG_DATABASE_MYSQL_APPSERVER][CONFIG_DATABASE_MYSQL_MASTER][CONFIG_DATABASE_MYSQL_DB];
            $this->strQuery = '-- noinspection SqlDialectInspection
                               SELECT AVG_ROW_LENGTH
                               FROM information_schema.tables
                               WHERE table_schema = "' . $schema . '"
                               AND table_name = "' . $this->collectionName . '"';
            $this->recordLimit = PDO_RECORDS_PER_PAGE;
            $this->executeNonPreparedQuery();
            if (($this->rowsAffected === 1) and (isset($this->queryResult[0][MYSQL_AVG_ROW_LENGTH]))) {
                $arl = $this->queryResult[0][MYSQL_AVG_ROW_LENGTH];
                if (($arl * PDO_RECORDS_PER_PAGE) > MYSQL_MAX_DATA_RETURNED) {
                    $this->recordLimit = intval(MYSQL_MAX_DATA_RETURNED / $arl);
                }
            }
            $cacheData[PDO_RECORDS_PER_PAGE] = $this->recordLimit;
            if (!gasCache::add(PDO_DATA_DEFINITION . '_' . PDO_AVG_ROW_LEN . '_' . $this->collectionName, gzcompress(json_encode($cacheData, true)), gasCache::$cacheTTL)) {
                $this->logger->warn('memcache:set failed - check log files');
            }
        }
    }


    /**
     * executeNonPreparedQuery() -- private method
     *
     * this is the main method to execute all META and SELECT queries - any query that is not a prepared statement
     * will execute here.  Basically, namaste queries.
     *
     * upon invocation, the string passed (implicitly through the member variable: $query) will be cleaned through
     * the common function, and then we'll evaluate the query based on the setting of the member variable $dbEvent.
     * If $dbEvent is not META and not SELECT, then we're going to return with a WARN message requiring the client
     * to use the prepared-query method.
     *
     * Next, parse the query and look for the "?" character - which is used as a place holder in prepared queries,
     * and, if found, reject the request and return with a WARN message.
     *
     * Call a private method to see if the slave server is enabled and, if so, use it if the current query contains
     * the SELECT keyword (meta queries will not use SELECT) and return the connection resource to a local variable.
     *
     * if query timers are enabled, then mark the start time and execute the query.  record the end-time and log
     * the query through the parent::method().
     *
     * Make a call to fetch the data as an associative array and post the results, along with the row count, to
     * class variables.
     *
     * if the query generated an mysql error, generate an WARN message and return.
     *
     * @author  mike@givingassistant.org
     * @version 1.0
     *
     * HISTORY:
     * --------
     * 06-30-17     mks     original coding
     *
     */
    private function executeNonPreparedQuery()
    {
        if ($this->trace) $this->logger->trace(STRING_ENT_METH . __METHOD__);

        $startTime = floatval(0);

        if ($this->debug) {
            $this->logger->debug($this->strQuery);
        }

        // todo:  can I exec this schema command using the read-slave?  Do I want to?
        /** @var $dbLink mysqli() */
        $dbLink = $this->connection;

        if ($this->useTimers) $startTime = floatval(0);
        $this->queryResults = null;
        if ($this->dbEvent != DB_EVENT_NAMASTE) {
            $this->strQuery = cleanQueryString($this->strQuery);
        }

        switch($this->dbEvent) {
            case DB_EVENT_NAMASTE :
            case DB_EVENT_SELECT :
                break;
            default :
                $this->logger->error(ERROR_SQL_NOT_PREP_STMNT);
                return;
        }
        if (stripos($this->strQuery, '?')) {
            $this->logger->warn(ERROR_SQL_LOST_PREP_QUERY);
            $this->logger->warn($this->strQuery);
            return;
        }

        if ($this->useTimers) {
            $startTime = gasStatic::doingTime();
        }
        if ($result = $dbLink->query($this->strQuery)) {
            $this->rowsAffected = $result->num_rows;
            if ($this->useTimers) {
                $this->logger->metrics($this->strQuery, gasStatic::doingTime($startTime));
                $this->logger->debug(MYSQL_ROWS_AFFECTED . $this->rowsAffected);
            }
            while ($row = $result->fetch_assoc()) {
                $this->queryResult[] = $row;
            }
        } else {
            $this->logger->warn('error expecting query: ' . $this->strQuery);
        }
    }


    /**
     * loadTemplate() -- private method
     *
     * this method is invoked by the constructor and serves to load the class template file, assimilating it into
     * the current instantiation.
     *
     * template loads are done on the schema-instantiation level, instead of the core, because of the changes in
     * the template file(s) across various schemas.
     *
     * the method will load the class template and set the class member variables controlled/referenced by the
     * template.
     *
     * successful loading of the template is determined by the return (boolean) value -- on error, a log message
     * will be generated so it's up to the developer to check logs on fail-returns to see why their template
     * file was not correctly assimilated.
     *
     * The template to be loaded is first derived in the constructor (post validation that the template file
     * exists) and is pulled from the member variable (also set in the constructor) within this method.
     *
     *
     * @author  mike@givingassistant.org
     * @version 1.0
     *
     * @return bool
     *
     * HISTORY:
     * ========
     * 06-30-17     mks     original coding
     *
     */
    private function loadTemplate():bool
    {
        if ($this->trace) $this->logger->trace(STRING_ENT_METH . __METHOD__);

        try {
            /** @var gatTestMySQL template */
            $this->template = new $this->templateName;
        } catch (Exception $e) {
            $this->logger->warn($e->getMessage());
            $this->state = STATE_FRAMEWORK_FAIL;
            return (false);
        }

        if (!is_object($this->template)) {
            $this->logger->warn(ERROR_FILE_404 . $this->templateName);
            $this->setState(ERROR_FILE_404 . $this->templateName);
            return (false);
        }

        if ($this->template->schema != TEMPLATE_DB_PDO) {
            $this->logger->warn(ERROR_SCHEMA_MISMATCH . $this->template->schema . ERROR_STUB_EXPECTING . TEMPLATE_DB_PDO);
            $this->setState(ERROR_SCHEMA_MISMATCH . $this->templateName);
            return (false);
        }

        // transfer meta data info to current instantiation
        $this->schema = $this->template->schema;
        $this->collectionName = $this->template->collection;
        $this->ext = $this->template->extension;
        $this->useCache = $this->template->setCache;
        $this->useDeletes = $this->template->setDeletes;
        $this->useAuditing = $this->template->setAuditing;
        $this->useJournaling = $this->template->setJournaling;
        $this->allowUpdates = $this->template->setUpdates;
        $this->useDetailedHistory = $this->template->setHistory;
        $this->defaultStatus = $this->template->setDefaultStatus;
        $this->searchStatus = $this->template->setSearchStatus;
        $this->useLocking = $this->template->setLocking;
        $this->useTimers = ($this->template->setTimers and gasConfig::$settings[CONFIG_DATABASE][CONFIG_DATABASE_QUERY_TIMERS]);
        $this->pKey = $this->template->setPKey;
        $this->useToken = $this->template->setTokens;
        $this->cacheExpiry = $this->template->cacheTimer;

        if (isset($this->template->fields) and is_array($this->template->fields)) {
            foreach ($this->template->fields as $key => $value) {
                if ($key == DB_HISTORY) {
                    $this->fieldList[] = $key;
                    $this->fieldTypes[$key] = $value;
                } else {
                    $this->fieldList[] = ($key . $this->ext);
                    $this->fieldTypes[($key . $this->ext)] = $value;
                }
            }
        }

        if (isset($this->template->indexes) and is_array($this->template->indexes)) {
            foreach ($this->template->indexes as $key => $value) {
                $this->indexes[] = ($key . $this->ext);
            }
        }


        if (!is_null($this->template->cacheMap) and $this->useCache) {
            foreach ($this->template->cacheMap as $key => $value) {
                $this->cacheMap[($key . $this->ext)] = $value;
            }
        } elseif (!$this->useCache) {
            $this->cacheMap = null;
            if (!is_null($this->template->exposedFields)) {
                $this->exposedFields = $this->template->exposedFields;
            }
        }

        if (!is_null($this->template->uniqueIndexes)) $this->uniqueIndexes = $this->template->uniqueIndexes;

        if (!is_null($this->template->compoundIndexes)) $this->compoundIndexes = $this->template->compoundIndexes;

        if (!is_null($this->template->binFields)) {
            foreach ($this->template->binFields as $key) {
                $this->binaryFields[] = ($key . $this->ext);
            }
        }

        if ($this->template->selfDestruct) {
            unset($this->template);
        }
        return (true);
    }


    protected function _createRecord($_data)
    {

    }

    protected function _fetchRecords($_dd, $_rd = null, $_co = true, $_skip = 0, $_limit = 0, $_sort = null)
    {

    }

    protected function _updateRecord($_data){

    }

    protected function _deleteRecord($_data)
    {

    }

    protected function _lockRecord()
    {

    }

    protected function _releaseLock()
    {

    }

    protected function _isLocked()
    {

    }


    /**
     * __destruct() -- public function
     *
     * class destructor
     *
     * @author  mike@givingassistant.org
     * @version 1.0
     *
     * HISTORY:
     * ========
     * 06-29-17     mks     original coding
     *
     */
    public function __destruct()
    {
        // As of PHP 5.3.10 destructors are not run on shutdown caused by fatal errors.
        //
        // destructor is registered shut-down function in constructor -- so any recovery
        // efforts should go in this method.

        // there is no destructor method defined in the core abstraction class, hence
        // there is no call to that parent destructor in this class.
        parent::__destruct();
    }
}