<?php
/*
 ** Job Arranger Manager
 ** Copyright (C) 2024 Daiwa Institute of Research Ltd. All Rights Reserved.
 **
 ** Licensed to the Apache Software Foundation (ASF) under one or more
 ** contributor license agreements. See the NOTICE file distributed with
 ** this work for additional information regarding copyright ownership.
 ** The ASF licenses this file to you under the Apache License, Version 2.0
 ** (the "License"); you may not use this file except in compliance with
 ** the License. You may obtain a copy of the License at
 **
 ** http://www.apache.org/licenses/LICENSE-2.0
 **
 ** Unless required by applicable law or agreed to in writing, software
 ** distributed under the License is distributed on an "AS IS" BASIS,
 ** WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 ** See the License for the specific language governing permissions and
 ** limitations under the License.
 **
 **/

namespace App\Utils;

use PDO, PDOException;
use App\Controllers\Pages;
use App\Utils\Constants;
use App\Exceptions\DbConnectionException;
use App\Utils\Monolog;
use Exception;

/**
 * This class is used to manage the database connection.
 *
 * @version    6.1.0
 * @since      Class available since version 6.1.0
 */
class Database
{
    private $dsn;
    private $dbHost = DB_HOST;
    private $dbUser = DB_USER;
    private $dbPass = DB_PASS;
    private $dbName = DB_NAME;
    private $dbPort = DB_PORT;
    private $encoding;
    private $useTls = DB_ENCRYPTION; // Use this parameter to determine if TLS is needed
    private $keyFile = DB_KEY_FILE;
    private $certFile = DB_CERT_FILE;
    private $caFile = DB_CA_FILE;
    private $verifyHost = DB_VERIFY_HOST;
    private $cipherList = DB_CIPHER_LIST;
    private $sslVer = SSL_VERIFICATION;

    private $options = array(
        PDO::ATTR_PERSISTENT => true,
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_CASE => PDO::CASE_NATURAL,
        PDO::ATTR_STRINGIFY_FETCHES => true
    );

    static $_instance;
    private $statement;
    private $dbHandler;
    private $error;

    protected $maxReconnectTries = 3;
    protected $reconnectErrors = [
        1317 // interrupted
        ,
        2002 // refused
        ,
        2006 // gone away
    ];
    protected $reconnectTries = 0;
    protected $reconnectDelay = 300; // in ms

    private $properties = [];
    /**
     * @var object
     */
    private $logger;
    /**
     * @var object
     */


    /**
     * It starts database connection.
     *
     * @param  object  $logger  
     * @throws DbConnectionException
     * @since      Class available since version 6.1.0
     */
    public function __construct($logger, $configPrefix = '')
    {
        $this->dbHost = constant($configPrefix . 'DB_HOST');
        $this->dbUser = constant($configPrefix . 'DB_USER');
        $this->dbPass = constant($configPrefix . 'DB_PASS');
        $this->dbName = constant($configPrefix . 'DB_NAME');
        $this->dbPort = constant($configPrefix . 'DB_PORT');

        $this->useTls      = constant($configPrefix . 'DB_ENCRYPTION') ?? 0;
        $this->caFile      = constant($configPrefix . 'DB_CA_FILE') ?? '';
        $this->keyFile     = constant($configPrefix . 'DB_KEY_FILE') ?? '';
        $this->certFile    = constant($configPrefix . 'DB_CERT_FILE') ?? '';
        $this->verifyHost  = constant($configPrefix . 'DB_VERIFY_HOST') ?? 0;
        $this->cipherList  = constant($configPrefix . 'DB_CIPHER_LIST') ?? '';

        $this->logger = $logger;

        try {
            $this->setDsn();
            $this->getConnection();

            $this->logger->debug('DB is successfully connected.', ['controller' => __METHOD__]);
        } catch (PDOException $e) {

            $this->error = $e->getMessage();
            $this->logger->error($e->getMessage(), ['controller' => __METHOD__]);
            throw new DbConnectionException($e->getMessage(), (int) $e->getCode());
        }
    }

    /**
     * Set the DSN string based on the database type (MySQL/PostgreSQL).
     */
    private function setDsn()
    {
        // Validate SSL files if TLS is being used
        if ($this->useTls) {

            if (DATA_SOURCE_NAME === Constants::DB_MYSQL) {

                $this->dsn = 'mysql:host=' . $this->dbHost . ';port=' . $this->dbPort . ';dbname=' . $this->dbName;
                if (file_exists($this->caFile)) {
                    $this->options[PDO::MYSQL_ATTR_SSL_CA] = $this->caFile;
					$this->options[PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT] = true; // Enable verification when using SSL

                    if (!empty($this->keyFile) && !empty($this->certFile)) {
                        $this->options[PDO::MYSQL_ATTR_SSL_KEY] = $this->keyFile;
                        $this->options[PDO::MYSQL_ATTR_SSL_CERT] = $this->certFile;
                    }

                    if (!empty($this->cipherList)) {
                        $this->options[PDO::MYSQL_ATTR_SSL_CIPHER] = $this->cipherList;
                    }
					
                } else {
                    // If no CA file but SSL is required
                    $this->options[PDO::MYSQL_ATTR_SSL_CA] = true;
					$this->options[PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT] = false;
                }

                $this->encoding = Constants::MYSQL_UTF8_ENCODING;
            } elseif (DATA_SOURCE_NAME === Constants::DB_PGSQL) {
                $this->dsn = 'pgsql:host=' . $this->dbHost . ';port=' . $this->dbPort . ';dbname=' . $this->dbName;

                // Add TLS options for PostgreSQL
                if (!empty($this->caFile)) {
                    $params = [
                        'sslmode' => $this->verifyHost ? 'verify-full' : 'verify-ca',
                        'sslkey' => $this->keyFile,
                        'sslcert' => $this->certFile,
                        'sslrootcert' => $this->caFile
                    ];

                    // Append parameters to DSN
                    foreach ($params as $key => $value) {
                        if (!empty($value)) {
                            $this->dsn .= ';' . $key . '=' . $value;
                        }
                    }
                }
                $this->encoding = Constants::PGSQL_UTF8_ENCODING;
            }
        } else {
            if (DATA_SOURCE_NAME === Constants::DB_MYSQL) {
                $this->dsn = 'mysql:host=' . $this->dbHost . ';port=' . $this->dbPort . ';dbname=' . $this->dbName;
                $this->encoding = Constants::MYSQL_UTF8_ENCODING;
            } elseif (DATA_SOURCE_NAME === Constants::DB_PGSQL) {
                $this->dsn = 'pgsql:host=' . $this->dbHost . ';port=' . $this->dbPort . ';dbname=' . $this->dbName;
                $this->encoding = Constants::PGSQL_UTF8_ENCODING;
            }
        }
    }

    /**
     * Establish the database connection.
     */
    public function getConnection()
    {
        try {
            if (!$this->dbHandler) {
                $conn = $this->dsn . $this->encoding;
                $this->dbHandler = new PDO($conn, $this->dbUser, $this->dbPass, $this->options);
                if ($this->useTls) {
                    if (!$this->isConnectionSecure()) {
                        throw new DbConnectionException(Constants::DATABASE_CONNECTION_FAIL);
                    }
                }
            }
        } catch (PDOException $e) {
            // Log the error
            $this->logger->error("Database connection error: " . $e->getMessage(), ['controller' => __METHOD__]);
            echo Util::response(Constants::API_RESPONSE_ERROR_CONNECTION_DATABASE, [Constants::API_RESPONSE_MESSAGE => $e->getMessage()]);
            exit;
        }
    }


    /**
     * Check if the current connection uses SSL and contains the requested cipher list.
     *
     * @return bool
     */
    public function isConnectionSecure()
    {
        try {
            if (DATA_SOURCE_NAME === Constants::DB_MYSQL) {
                // Temporarily disable persistent connection for the check
                if (!empty($this->keyFile) && !empty($this->certFile)) {
                    if (!file_exists($this->keyFile) || !file_exists($this->certFile)) {
                        $this->logger->error("Client certificate file doesn't exists " . $this->keyFile . " or " . $this->certFile, ['controller' => __METHOD__]);
                        return false;
                    }

                }
                $originalOptions = $this->options;
                $this->options[PDO::ATTR_PERSISTENT] = false;

                // Force a fresh connection without persistence
                $conn = $this->dsn . $this->encoding;
                $tempDbHandler = new PDO($conn, $this->dbUser, $this->dbPass, $this->options);

                // Perform the SSL cipher check
                $stmt = $tempDbHandler->query("SHOW STATUS LIKE 'Ssl_cipher'");
                $result = $stmt->fetch(PDO::FETCH_ASSOC);

                // Close the temporary connection
                $tempDbHandler = null;

                // Restore original options (including persistence)
                $this->options = $originalOptions;

                if (!$result || empty($result['Value'])) {
                    $this->logger->error('Error connecting to database. Connection is not secure.', ['controller' => __METHOD__]);
                    return false;
                }

                return true;
            } else {
                // Handle PostgreSQL SSL check
                $originalOptions = $this->options;
                $this->options[PDO::ATTR_PERSISTENT] = false;

                // Create temporary connection to PostgreSQL
                $conn = $this->dsn . $this->encoding;
                $tempDbHandler = new PDO($conn, $this->dbUser, $this->dbPass, $this->options);

                // Check the SSL status using SHOW ssl query
                $stmt = $tempDbHandler->query('SHOW ssl');
                $sslRow = $stmt->fetch(PDO::FETCH_ASSOC);
                $isSecure = ($sslRow && $sslRow['ssl'] === 'on');

                // Close the temporary connection
                $tempDbHandler = null;

                // Restore original options (including persistence)
                $this->options = $originalOptions;

                if (!$isSecure) {
                    $this->logger->error('Error connecting to database. Connection is not secure.', ['controller' => __METHOD__]);
                    return false;
                }

                return true;
            }
        } catch (PDOException $e) {
            // Restore original options in case of an exception
            $this->options = $originalOptions;

            $this->logger->error('Error checking SSL cipher: ' . $e->getMessage());
            return false;
        }
    }

    /**
     * It reconnect to database.
     *
     * @throws Exception
     * @throws DbConnectionException
     * @since      Class available since version 6.1.0
     */
    public function reconnect()
    {
        $connected = false;
        while (!$connected && $this->reconnectTries < $this->maxReconnectTries) {
            usleep($this->reconnectDelay * 1000); // micro second
            ++$this->reconnectTries;
            $this->dbHandler = null;
            try {
                $this->getConnection();
                if ($this->dbHandler) {
                    $connected = true;
                }
            } catch (Exception $e) {
            }
        }
        if (!$connected) {
            throw new DbConnectionException(Constants::DATABASE_CONNECTION_FAIL);
        }
    }

    /**
     * It allows us to write queries.
     *
     * @since      Class available since version 6.1.0
     */
    public function query($sql)
    {
        $this->statement = $this->dbHandler->prepare($sql);
    }

    /**
     * It binds value.
     *
     * @since      Class available since version 6.1.0
     */
    public function bind($parameter, $value, $type = null, bool $logFlag = true)
    {
        switch (is_null($type)) {
            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;
        }
        $this->statement->bindValue($parameter, $value, $type);
        if ($logFlag) {
            $this->logger->debug('Bind Parameter ' . $parameter . ', Bind Value :' . $value, ['controller' => __METHOD__]);
        }
    }

    /**
     * It executes the prepared statement.
     *
     * @since      Class available since version 6.1.0
     */
    public function execute(bool $logFlag = true)
    {
        if ($logFlag) {
            $this->logger->debug('Preparing : ' . $this->statement->queryString, ['controller' => __METHOD__, 'user' => isset($_SESSION['userInfo']['userName']) ? $_SESSION['userInfo']['userName'] : '']);
        }

        try {
            $result = $this->statement->execute();
            return $result;
        } catch (PDOException $e) {
            throw new PDOException($e->getMessage(), (int) $e->getCode());
            $this->logger->debug($e->getMessage(), ['controller' => __METHOD__, 'user' => isset($_SESSION['userInfo']['userName']) ? $_SESSION['userInfo']['userName'] : '']);
        }
    }

    //Return an array
    public function resultSet(string $method = "", bool $bolCamelCase = false, bool $logFlag = true)
    {

        if (is_bool($method)) {
            $bolCamelCase = boolval($method);
            $method = "";
        }

        $this->execute($method);

        if ($bolCamelCase) {
            $result = $this->changeCaseInResult($this->statement->fetchAll(PDO::FETCH_OBJ));
        } else {
            $result = $this->statement->fetchAll(PDO::FETCH_OBJ);
        }
        if ($logFlag) {
            $this->logger->debug('Result : ' . count($result), ['controller' => __METHOD__, 'user' => isset($_SESSION['userInfo']['userName']) ? $_SESSION['userInfo']['userName'] : '']);
        }
        return $result;
    }

    public function resultSetAsArrayInCamelCase(string $method = "", bool $logFlag = true)
    {
        return $this->resultSet($method, true, $logFlag);
    }

    public function resultSetAsArray($objName = "stdClass")
    {
        $this->execute();
        $result = $this->statement->fetchAll(PDO::FETCH_ASSOC);
        return $result;
    }

    public function resultSetByParams($sqlQuery = "", $sqlParams = "")
    {
        $this->query($sqlQuery);
        if ($sqlParams == "") {
            $this->statement->execute();
        } else {
            $this->statement->execute($sqlParams);
        }

        $result = $this->changeCaseInResult($this->statement->fetchAll(PDO::FETCH_OBJ));

        return $result;
    }

    public function singleValueByParam($sqlQuery = "", $sqlParam = "")
    {
        $this->query($sqlQuery);
        if ($sqlParam == "") {
            $this->statement->execute();
        } else {
            $this->bind(":param", $sqlParam);
            $this->statement->execute();
        }

        $result = $this->statement->fetchAll(PDO::FETCH_COLUMN);
        return (isset($result[0]) ? $result[0] : "");
    }

    //Return a specific row as an object
    public function single(string $method = "", bool $bolCamelCase = false)
    {

        if (is_bool($method)) {
            $bolCamelCase = boolval($method);
            $method = "";
        }

        $this->execute($method);

        if ($bolCamelCase) {
            $result = $this->changeCaseInSingle($this->statement->fetch(PDO::FETCH_OBJ));
        } else {
            $result = $this->statement->fetch(PDO::FETCH_OBJ);
        }
        return $result;
    }

    public function singleAsArrayInCamelCase(string $method = "")
    {
        return $this->single($method, true);
    }

    //Get's the row count
    public function rowCount($method = "", bool $logFlag = true)
    {
        $result = $this->statement->rowCount();
        if ($logFlag) {
            $this->logger->debug('Result : ' . $result, ['controller' => $method]);
        }

        return $result;
    }

    private function changeCaseInResult($result)
    {

        $resultArray = array();

        foreach ($result as $k => $v) {
            $arr = json_decode(json_encode($v), true);
            foreach ($arr as $kk => $vv) {
                $newKK = $this->underscoreToCamelCase($kk);
                $arr = $this->replaceArrayKey($arr, $kk, $newKK);
            }
            array_push($resultArray, $arr);
        }

        return $resultArray;
    }
    private function changeCaseInSingle($result)
    {

        $resultArray = array();
        if (false != $result) {
            $arr = json_decode(json_encode($result), true);
            foreach ($arr as $kk => $vv) {
                $newKK = $this->underscoreToCamelCase($kk);
                $arr = $this->replaceArrayKey($arr, $kk, $newKK);
            }
            array_push($resultArray, $arr);
        }
        return $resultArray;
    }

    private function underscoreToCamelCase($string, $capitalizeFirstCharacter = false)
    {
        $str = $string ? str_replace('_', '', ucwords($string, '_')) : '';
        if (!$capitalizeFirstCharacter) {
            $str = lcfirst($str);
        }
        return $str;
    }

    private function replaceArrayKey($array, $oldKey, $newKey)
    {
        //Get a list of all keys in the array.
        $arrayKeys = array_keys($array);
        //Replace the key in our $arrayKeys array.
        $oldKeyIndex = array_search($oldKey, $arrayKeys);
        $arrayKeys[$oldKeyIndex] = $newKey;
        //Combine them back into one array.
        $newArray = array_combine($arrayKeys, $array);
        return $newArray;
    }

    public function getDbHandler()
    {
        return $this->dbHandler;
    }

    public function getError()
    {
        return $this->error;
    }

    public function beginTransaction()
    {
        return $this->dbHandler->beginTransaction();
    }

    public function commit()
    {
        return $this->dbHandler->commit();
    }

    public function rollback()
    {
        return $this->dbHandler->rollBack();
    }

    public function inTransaction()
    {
        return $this->dbHandler->inTransaction();
    }
}
