<?php
/**
 * CmsPdo
 *
 * @package mtc ecommerce
 * @author Nick <nick.edwards@mtcmedia.co.uk>
 * @copyright 2013 mtc. http://www.mtcmedia.co.uk/
 * @version 1.3
 * @access public
 */
class CmsPdo extends PDO
{
    // statement handler
    public $sth;
    private static string $db = '';
    private static string $host = '';
    private static string $user = '';
    private static string $pass = '';

    /**
     * @var PDO
     */
    private static PDO $PDOInstance;

    /**
     * @return PDO
     */
    public static function getPDOInstance()
    {
        return self::$PDOInstance;
    }

    /**
     * CmsPdo::__construct()
     *
     * Opens up PDO database connection.
     * Sets default charset to UTF-8.
     * Sets error reporting on if in development mode.
     *
     * @return PDO
     * @throws Exception
     */
    public function __construct()
    {
        self::$host = MYSQL_HOST;
        self::$db = MYSQL_DB;
        self::$user = MYSQL_USER;
        self::$pass = MYSQL_PASS;
        if (empty(self::$PDOInstance)) {
            $this->connectToDB();
        }
        return self::$PDOInstance;
    }


    public function connectToDB(): void
    {
        try {
            // Connect to the DB
            self::$PDOInstance = new PDO("mysql:host=" . self::$host . ";dbname=" . self::$db, self::$user, self::$pass);

            $this->run("SET NAMES utf8");
        } catch (PDOException $e) {
            if (DEV_MODE) {
                throw new Exception($e->getMessage());
            }
        }
    }

    /**
     * CmsPdo::connect()
     *
     * Opens up PDO database connection.
     * Sets default charset to UTF-8.
     * Sets error reporting on if in development mode.
     *
     * @param string $dsn
     * @param string|null $username
     * @param string|null $password
     * @param array|null $options
     * @return CmsPdo
     * @throws Exception
     */
    public static function connect(string $dsn, ?string $username = null, ?string $password = null, ?array $options = null): static
    {

        try {

            // Connect to the DB
            self::$PDOInstance = new PDO("mysql:host=" . self::$host . ";dbname=" . self::$db, self::$user, self::$pass);

        } catch (PDOException $e) {
            if (DEV_MODE) {
                throw new Exception($e->getMessage());
            }
        }
        $instance = new self();

        // Set error mode
        self::$PDOInstance->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        $instance->run("SET NAMES utf8");

        return $instance;
    }

    /**
     * CmsPdo::run()
     *
     * Main query function which will build up the query you pass in and bind all
     * the correct parameters.
     *
     * @param  mixed $sql       - sql statement, using :variable where
     * appropriate
     * @param  mixed $params    - key => value array of :variable => value
     * @param  mixed $fetchmode - set what to return, defaults to associative
     * array, allows an array with arguments for setFetchMode()
     *
     * @throws Exception If setting fetch mode with the new method did not work
     * @return PDOStatement|bool The resulting PDO statement that you can run
     * ->fetch() on disregarding the given $fetchmode parameter.
     */
    public function run($sql, $params = array(), $fetchmode = array(PDO::FETCH_ASSOC))
    {
        //echo $GLOBALS['queries'];
//        echo $sql."<br />";
//        var_dump(microtime(true) - START_TIME);
        //$GLOBALS['queries']++;

        self::prepareValues($sql, $params);

        try {
            $this->sth->execute();
        } catch (PDOException $e) {
            if ($e->getCode() === 2006) { // check if MySQL server has gone away
                try{
                    //reconnect
                    $this->connectToDB();
                    self::prepareValues($sql, $params);
                    $this->sth->execute();
                }catch(PDOException  $e){
                    if (DEV_MODE) {
                        throw new Exception($e->getMessage());
                    }
                    return false;
                }
            } else {
                if (DEV_MODE) {
                    throw new Exception($e->getMessage());
                }
                return false;
            }
        }

        // Allow proper fetchmode setup, e.g. PDO::FETCH_CLASS
        // unpacks the array and passes arguments to the function
        // @author Georgi Boiko <georgi.boiko@mtcmedia.co.uk>
        if (is_array($fetchmode)) {
            if (call_user_func_array(array(&$this->sth, 'setFetchMode'), $fetchmode) == false) {
                // throw an exception
                if (DEV_MODE) {
                    throw new Exception('Failed to set fetch mode');
                }
                return false;
            }
        } else {
            // Support for legacy code which uses a constant instead of an array as an argument
            $this->sth->setFetchMode($fetchmode);
        }
        return $this->sth;
    }

    /**
     * CmsPdo::prepare_values()
     *
     * Secondary query function that prepare sql values and params.
     *
     * @param  mixed  $sql       - sql statement
     * @param  mixed  $params    - sql params
     * @return void
     */
    private function prepareValues($sql, $params)
    {
        $this->sth = self::$PDOInstance->prepare($sql);
        if (sizeof($params) > 0) {
            foreach ($params as $field => $value) {
                // PDO casts everything as a string, unless you typecast the value
                if (is_int($value) || $value === "0") { // turns out is_int("0") evaluates as false?
                    $type = PDO::PARAM_INT;
                    $value = (int) $value;
                } elseif (is_bool($value)) {
                    $type = PDO::PARAM_BOOL;
                    $value = (bool) $value;
                } elseif (is_null($value)) {
                    $type = PDO::PARAM_STR;
                    $value = (string) '';
                } else {
                    $type = PDO::PARAM_STR;
                    if (is_float($value)) {
                        // apparently there is no PDO::PARAM_* for floats, so best to treat them as strings
                        $value = (float) $value;
                    }
                }
                // Add support for executing a prepared statement with question mark placeholders
                //  where param values are added via $array[] = "value";
                if (is_numeric($field)) {
                    $field++;
                }
                $this->sth->bindValue($field, $value, $type);
            }
        }
    }

    /**
     * CmsPdo::query()
     *
     * Secondary query function that does not support prepared statements but it
     * does support multi query.
     *
     * @param  mixed  $sql       - sql statement
     * @return object statement handler
     */
    public function query(string $query, ?int $fetchMode = null, mixed ...$fetchModeArgs): PDOStatement | false
    {
        $this->sth = self::$PDOInstance;
        $pdo_statement = $this->sth->query($query);

        return $pdo_statement;
    }

    /**
     * CmsPdo::fetch_array()
     *
     * Alternative function for PDOStatement::fetch allowing backwards code
     * compatibility for old mysql_* class
     *
     * @return object statement handler
     */
    public function fetch_array()
    {
        return $this->sth->fetch();
    }

    /**
     * CmsPdo::num_rows()
     *
     * Alternative function for PDOStatement::rowCount allowing backwards code
     * compatibility for old mysql_* class
     *
     * @return int number of rows
     */
    public function num_rows()
    {
        return $this->sth->rowCount();
    }

    /**
     * CmsPdo::insert()
     *
     * Builds query from field => value array
     * Allows mysql keywords to be used in value - e.g. now() or NULL
     *
     * @param  mixed $table
     * @param  mixed $data
     * @return mixed insert id on success, false on fail
     */
    public function insert($table, $data)
    {
        $q = "INSERT INTO `$table` ";
        $v = '';
        $n = '';

        $params = array();

        foreach ($data as $key => $val) {
            $n .= "`$key`, ";
            if (strtolower($val) == 'null') {
                $v .= "NULL, ";
            } elseif (strtolower($val) == 'now()') {
                $v .= "NOW(), ";
            } else {
                $v .= ":" . $key . ", ";
                $params[':' . $key] = $val;
            }
        }

        $q .= "(" . rtrim($n, ', ') . ") VALUES (" . rtrim($v, ', ') . ");";

        if ($this->run($q, $params)) {
            return self::$PDOInstance->lastInsertId();
        } else {
            return false;
        }
    }

    /**
     * CmsPdo::update()
     *
     * Builds update query from field => value array
     * Accepts a number of keywords as values:
     *     null         -> NULL
     *     now()        -> NOW()
     *     increment(i) -> increment value by i where i is an integer
     *     decrement(i) -> decrement value by i where i is an integer
     *
     * Where clause default so you don't go accidentally updating whole table
     *
     * @param  mixed  $table
     * @param  mixed  $data
     * @param  string $where
     * @param  mixed  $params
     * @return bool   true on success, false on fail
     */
    public function update($table, $data, $where = '`id` = :id', $params = array(':id' => 0))
    {
        $q = "UPDATE `$table` SET ";

        foreach ($data as $key => $val) {
            if (strtolower($val) == 'null') {
                $q .= "`$key` = NULL, ";
            } elseif (strtolower($val) == 'now()') {
                $q .= "`$key` = NOW(), ";
            } elseif (preg_match("/^increment\((\-?\d+)\)$/i", $val, $m)) {
                $q .= "`$key` = `$key` + $m[1], ";
            } elseif (preg_match("/^decrement\((\-?\d+)\)$/i", $val, $m)) {
                $q .= "`$key` = `$key` - $m[1], ";
            } else {
                $q .= "`$key`=:" . $key . ", ";
                $params[':' . $key] = $val;
            }
        }

        $q = rtrim($q, ', ') . ' WHERE ' . $where;

        return $this->run($q, $params);
    }

    /**
     * CmsPdo::delete()
     *
     * Builds delete query
     * Where clause default so you don't delete whole table's data
     *
     * @param  mixed  $table
     * @param  string $where
     * @param  mixed  $params
     * @return bool   true on success, false on fail
     */
    public function delete($table, $where = '`id` = :id', $params = array(':id' => 0))
    {
        $q = "DELETE FROM `$table` ";
        $q .= " WHERE " . $where;

        return $this->run($q, $params);
    }

    /**
     * cleanDB
     *
     * Cleans the input string to prevent SQL injection by escaping special characters.
     *
     * @param string $text The input string to be cleaned.
     * @return string The sanitized string, safe for use in database queries.
     */
    public static function cleanDB(string $text): string
    {
        $db = new self();
        $text = $db::getPDOInstance()->quote($text);
        return substr($text, 1, -1);
    }
}
