<?php

/* --------------------------------------------------------------------

  G\ library
  https://g.chevereto.com

  @author	Rodolfo Berrios A. <http://rodolfoberrios.com/>

  Copyright (c) Rodolfo Berrios <inbox@rodolfoberrios.com> All rights reserved.

  Licensed under the MIT license
  http://opensource.org/licenses/MIT

  --------------------------------------------------------------------- */

/**
 * class.db.php
 * This class does all the DB handling of the G\ app.
 */

namespace G;

use PDO;
use Exception;

class DB
{
    private static $instance;

    private $host = G_APP_DB_HOST;
    private $port = G_APP_DB_PORT;
    private $name = G_APP_DB_NAME;
    private $user = G_APP_DB_USER;
    private $pass = G_APP_DB_PASS;
    private $driver = G_APP_DB_DRIVER;
    private $pdo_attrs = G_APP_DB_PDO_ATTRS;

    public static $dbh;
    public $query;

    /**
     * Connect to the DB server
     * Throws an Exception on error (tay weando? en serio?).
     */
    public function __construct($conn = [])
    {
        try {
            // PDO already connected
            if (empty($conn) and isset(self::$dbh) and get_class(self::$dbh) == 'PDO') {
                return true;
            }

            if (!empty($conn)) {
                // Inject connection info
                foreach (['host', 'user', 'name', 'pass', 'port', 'driver', 'pdo_attrs'] as $k) {
                    $this->{$k} = $conn[$k];
                }
            }

            $pdo_connect = $this->driver.':host='.$this->host.';dbname='.$this->name;
            if ($this->port) {
                $pdo_connect .= ';port='.$this->port;
            }
            // $pdo_connect .= ';charset=UTF8';

            $this->pdo_attrs = @unserialize($this->pdo_attrs) ?: $this->pdo_attrs;

            // PDO defaults
            $this->pdo_default_attrs = [
                PDO::ATTR_TIMEOUT => 30,
            ];

            // Override PDO defaults ?
            $this->pdo_attrs = (is_array($this->pdo_attrs) ? $this->pdo_attrs : []) + $this->pdo_default_attrs;

            // PDO hard overrides
            $this->pdo_attrs[PDO::ATTR_ERRMODE] = PDO::ERRMODE_EXCEPTION;
            $this->pdo_attrs[PDO::MYSQL_ATTR_INIT_COMMAND] = "SET time_zone = '+00:00', NAMES 'utf8mb4'"; // UTC for timestamps

            // Turn off PHP error reporting just for the connection here (invalid host names will trigger a PHP warning)
            $error_reporting = error_reporting();
            error_reporting(0);

            // Note that PDO::ERRMODE_SILENT has no effect on connection. Connections always throw an exception if it fails
            self::$dbh = new PDO($pdo_connect, $this->user, $this->pass, $this->pdo_attrs);

            // Re-enable the error_reporting level
            error_reporting($error_reporting);

            // PDO emulate prepares if needed
            if (version_compare(self::$dbh->getAttribute(PDO::ATTR_SERVER_VERSION), '5.1.17', '<')) {
                self::$dbh->setAttribute(PDO::ATTR_EMULATE_PREPARES, true);
            }

            self::$instance = $this;
        } catch (Exception $e) {
            self::$dbh = null;
            throw new DBException($e->getMessage(), 400);
        }
    }

    /**
     * Singleton instance handler
     * Used for the static methods of this class.
     */
    public static function getInstance()
    {
        if (is_null(self::$instance)) {
            self::$instance = new self();
        }

        return self::$instance;
    }

    /**
     * Populates the class DB own PDO attributes array with an entire array
     * Attribute list here: http://php.net/manual/en/pdo.setattribute.php.
     */
    public function setPDOAttrs($attributes)
    {
        $this->pdo_attrs = $attributes;
    }

    /**
     * Populates the class DB own PDO attributes array with a single key
     * Attributes list here: http://php.net/manual/en/pdo.setattribute.php.
     */
    public function setPDOAttr($key, $value)
    {
        $this->pdo_attrs[$key] = $value;
    }

    public function getAttr($attr)
    {
        return self::$dbh->getAttribute($attr);
    }

    /**
     * Prepares an SQL statement to be executed by the PDOStatement::execute() method
     * http://php.net/manual/en/pdo.prepare.php.
     */
    public function query($query)
    {
        $this->query = self::$dbh->prepare($query);
    }

    public function errorInfo()
    {
        return self::$dbh->errorInfo();
    }

    /**
     * Binds a value to a corresponding named or question mark placeholder in the SQL statement that was used to prepare the statement
     * http://php.net/manual/en/pdostatement.bindvalue.php.
     */
    public function bind($param, $value, $type = null)
    {
        if (is_null($type)) {
            switch (true) {
                case is_int($value):
                    $type = PDO::PARAM_INT;
                break;
                case is_bool($value):
                    $type = PDO::PARAM_BOOL;
                break;
                case is_null($value):
                    $type = PDO::PARAM_NULL;
                break;
                default:
                    $type = PDO::PARAM_STR;
                break;
            }
        }
        $this->query->bindValue($param, $value, $type);
    }

    public function exec()
    {
        return $this->query->execute();
    }

    public function fetchColumn()
    {
        return $this->query->fetchColumn();
    }

    public function closeCursor()
    {
        return $this->query->closeCursor();
    }

    public function fetchAll($mode = PDO::FETCH_ASSOC)
    {
        $this->exec();

        return $this->query->fetchAll(is_int($mode) ? $mode : PDO::FETCH_ASSOC);
    }

    /**
     * Execute and returns the single result from the prepared statement
     * http://php.net/manual/en/pdostatement.fetch.php.
     */
    public function fetchSingle($mode = PDO::FETCH_ASSOC)
    {
        $this->exec();

        return $this->query->fetch(is_int($mode) ? $mode : PDO::FETCH_ASSOC);
    }

    /**
     * Query and exec, return number of affected rows or FALSE.
     */
    public static function queryExec($query)
    {
        try {
            $db = self::getInstance();
            $db->query($query);

            return $db->exec() ? $db->rowCount() : false;
        } catch (Exception $e) {
            throw new DBException($e->getMessage(), 400);
        }
    }

    /**
     * Query and fetch single record.
     */
    public static function queryFetchSingle($query, $fetch_style = null)
    {
        try {
            return self::queryFetch($query, 1, $fetch_style);
        } catch (Exception $e) {
            throw new DBException($e->getMessage(), 400);
        }
    }

    /**
     * Query and fetch all records.
     */
    public static function queryFetchAll($query, $fetch_style = null)
    {
        try {
            return self::queryFetch($query, null, $fetch_style);
        } catch (Exception $e) {
            throw new DBException($e->getMessage(), 400);
        }
    }

    /**
     * Query fetch (core version).
     */
    public static function queryFetch($query, $limit = 1, $fetch_style = null)
    {
        try {
            $db = self::getInstance();
            $db->query($query);

            return $limit == 1 ? $db->fetchSingle($fetch_style) : $db->fetchAll($fetch_style);
        } catch (Exception $e) {
            throw new DBException($e->getMessage(), 400);
        }
    }

    /**
     * Returns the number of rows affected by the last DELETE, INSERT, or UPDATE statement executed
     * http://php.net/manual/en/pdostatement.rowcount.php.
     */
    public function rowCount()
    {
        return $this->query->rowCount();
    }

    /**
     * Returns the ID of the last inserted row, or the last value from a sequence object, depending on the underlying driver
     * http://php.net/manual/en/pdo.lastinsertid.php.
     */
    public function lastInsertId()
    {
        return self::$dbh->lastInsertId();
    }

    /**
     * Turns off autocommit mode
     * http://php.net/manual/en/pdo.begintransaction.php.
     */
    public function beginTransaction()
    {
        return self::$dbh->beginTransaction();
    }

    /**
     * Commits a transaction, returning the database connection to autocommit mode until the next call to PDO::beginTransaction() starts a new transaction
     * http://php.net/manual/en/pdo.commit.php.
     */
    public function endTransaction()
    {
        return self::$dbh->commit();
    }

    /**
     * Rolls back the current transaction, as initiated by PDO::beginTransaction()
     * http://php.net/manual/en/pdo.rollback.php.
     */
    public function cancelTransaction()
    {
        return self::$dbh->rollBack();
    }

    /**
     * Dumps the informations contained by a prepared statement directly on the output
     * http://php.net/manual/en/pdostatement.debugdumpparams.php.
     */
    public function debugDumpParams()
    {
        return $this->query->debugDumpParams();
    }

    /* Now the G\ fast DB methods, presented by Chevereto */

    /**
     * Get the table with its prefix.
     */
    public static function getTable($table)
    {
        return get_app_setting('db_table_prefix').$table;
    }

    /**
     * Get values from DB.
     */
    public static function get($table, $values, $clause = 'AND', $sort = [], $limit = null, $fetch_style = null)
    {
        if (!is_array($values) and $values !== 'all') {
            throw new DBException('Expecting array values, '.gettype($values).' given in '.__METHOD__, 100);
        }

        self::validateClause($clause, __METHOD__);

        if (is_array($table)) {
            $join = $table['join'];
            $table = $table['table'];
        }

        $table = DB::getTable($table);

        $query = 'SELECT * FROM '.$table;

        if ($join) {
            $query .= ' '.$join.' ';
        }

        if (is_array($values) and !empty($values)) {
            $query .= ' WHERE ';
            foreach ($values as $k => $v) {
                if (is_null($v)) {
                    $query .= '`'.$k.'` IS :'.$k.' '.$clause.' ';
                } else {
                    $query .= '`'.$k.'`=:'.$k.' '.$clause.' ';
                }
            }
        }

        $query = rtrim($query, $clause.' ');

        if (is_array($sort) and !empty($sort)) {
            if (!$sort['field']) {
                $sort['field'] = 'date';
            }
            if (!$sort['order']) {
                $sort['order'] = 'desc';
            }
            $query .= ' ORDER BY '.$sort['field'].' '.strtoupper($sort['order']).' ';
        }

        if ($limit and is_int($limit)) {
            $query .= " LIMIT $limit";
        }

        try {
            $db = self::getInstance();
            $db->query($query);
            if (is_array($values)) {
                foreach ($values as $k => $v) {
                    $db->bind(':'.$k, $v);
                }
            }

            return $limit == 1 ? $db->fetchSingle($fetch_style) : $db->fetchAll($fetch_style);
        } catch (Exception $e) {
            throw new DBException($e->getMessage(), 400);
        }
    }

    /**
     * Update target table row(s)
     * Returns the number of affected rows or false.
     */
    public static function update($table, $values, $wheres, $clause = 'AND')
    {
        if (!is_array($values)) {
            throw new DBException('Expecting array values, '.gettype($values).' given in '.__METHOD__, 100);
        }
        if (!is_array($wheres)) {
            throw new DBException('Expecting array values, '.gettype($wheres).' given in '.__METHOD__, 100);
        }

        self::validateClause($clause, __METHOD__);

        $table = DB::getTable($table);

        $query = 'UPDATE `'.$table.'` SET ';

        // Set the value pairs
        foreach ($values as $k => $v) {
            $query .= '`'.$k.'`=:value_'.$k.',';
        }
        $query = rtrim($query, ',').' WHERE ';

        // Set the where pairs
        foreach ($wheres as $k => $v) {
            $query .= '`'.$k.'`=:where_'.$k.' '.$clause.' ';
        }
        $query = rtrim($query, $clause.' ');

        try {
            $db = self::getInstance();
            $db->query($query);

            // Bind the values
            foreach ($values as $k => $v) {
                $db->bind(':value_'.$k, $v);
            }
            foreach ($wheres as $k => $v) {
                $db->bind(':where_'.$k, $v);
            }

            return $db->exec() ? $db->rowCount() : false;
        } catch (Exception $e) {
            throw new DBException($e->getMessage(), 400);
        }
    }

    /**
     * Insert single row to the table.
     */
    public static function insert($table, $values)
    {
        if (!is_array($values)) {
            throw new DBException('Expecting array values, '.gettype($values).' given in '.__METHOD__, 100);
        }

        $table = DB::getTable($table);

        $table_fields = [];
        foreach ($values as $k => $v) {
            $table_fields[] = $k;
        }

        $query = 'INSERT INTO
					`'.$table.'` (`'.ltrim(implode('`,`', $table_fields), '`,`').'`)
					VALUES ('.':'.str_replace(':', ',:', implode(':', $table_fields)).')';

        try {
            $db = self::getInstance();
            $db->query($query);
            foreach ($values as $k => $v) {
                $db->bind(':'.$k, $v);
            }

            return $db->exec() ? $db->lastInsertId() : false;
        } catch (Exception $e) {
            throw new DBException($e->getMessage(), 400);
        }
    }

    /**
     * Update target numecic table row(s) with and increment (positive or negative)
     * Returns the number of affected rows or false
     * Note: Minimum value to be set is zero, no negative values here.
     */
    public static function increment($table, $values, $wheres, $clause = 'AND')
    {
        foreach (['values', 'wheres'] as $k) {
            if (!is_array(${$k})) {
                throw new DBException('Expecting array values, '.gettype(${$k}).' given in '.__METHOD__, 100);
            }
        }

        $table = DB::getTable($table);
        $query = 'UPDATE `'.$table.'` SET ';

        foreach ($values as $k => $v) {
            if (preg_match('/^([\+\-]{1})\s*([\d]+)$/', $v, $matches)) { // 1-> op 2-> number
                $query .= '`'.$k.'`=';
                if ($matches[1] == '+') {
                    $query .= '`'.$k.'`'.$matches[1].$matches[2].',';
                }
                if ($matches[1] == '-') {
                    $query .= 'GREATEST(cast(`'.$k.'` AS SIGNED) - '.$matches[2].', 0),';
                }
            }
        }

        $query = rtrim($query, ',').' WHERE ';

        // Set the where pairs
        foreach ($wheres as $k => $v) {
            $query .= '`'.$k.'`=:where_'.$k.' '.$clause.' ';
        }
        $query = rtrim($query, $clause.' ');

        try {
            $db = self::getInstance();
            $db->query($query);
            foreach ($wheres as $k => $v) {
                $db->bind(':where_'.$k, $v);
            }

            return $db->exec() ? $db->rowCount() : false;
        } catch (Exception $e) {
            throw new DBException($e->getMessage(), 400);
        }
    }

    /**
     * Delete row(s) from table
     * Returns the number of affected rows or false.
     */
    public static function delete($table, $values, $clause = 'AND')
    {
        if (!is_array($values)) {
            throw new DBException('Expecting array values, '.gettype($values).' given in '.__METHOD__, 100);
        }

        self::validateClause($clause, __METHOD__);

        $table = DB::getTable($table);
        $query = 'DELETE FROM `'.$table.'` WHERE ';

        $table_fields = array();
        foreach ($values as $k => $v) {
            $query .= '`'.$k.'`=:'.$k.' '.$clause.' ';
        }
        $query = rtrim($query, $clause.' ');

        try {
            $db = self::getInstance();
            $db->query($query);
            foreach ($values as $k => $v) {
                $db->bind(':'.$k, $v);
            }

            return $db->exec() ? $db->rowCount() : false;
        } catch (Exception $e) {
            throw new DBException($e->getMessage(), 400);
        }
    }

    /**
     * Validate clause.
     */
    private static function validateClause($clause, $method = null)
    {
        if (!is_null($clause)) {
            $clause = strtoupper($clause);
            if (!in_array($clause, ['AND', 'OR'])) {
                throw new DBException('Expecting clause string \'AND\' or \'OR\' in '.(!is_null($method) ? $method : __CLASS__), 100);
            }
        }
    }
}

// DB class own Exception
class DBException extends Exception
{
}
