<?php

declare(strict_types=1);

namespace SimpleSAML;

use Exception;
use PDO;
use PDOException;
use PDOStatement;
use SimpleSAML\Logger;

/**
 * This file implements functions to read and write to a group of database servers.
 *
 * This database class supports a single database, or a primary/secondary configuration with as many defined secondaries
 * as a user would like.
 *
 * The goal of this class is to provide a single mechanism to connect to a database that can be reused by any component
 * within SimpleSAMLphp including modules. When using this class, the global configuration should be passed here, but in
 * the case of a module that has a good reason to use a different database, such as sqlauth, an alternative config file
 * can be provided.
 *
 * @package SimpleSAMLphp
 */

class Database
{
    /**
     * This variable holds the instance of the session - Singleton approach.
     * @var \SimpleSAML\Database[]
     */
    private static array $instance = [];

    /**
     * PDO Object for the Primary database server
     */
    private PDO $dbPrimary;

    /**
     * Array of PDO Objects for configured database secondaries
     * @var \PDO[]
     */
    private array $dbSecondaries = [];

    /**
     * Prefix to apply to the tables
     */
    private string $tablePrefix;

    /**
     * Array with information on the last error occurred.
     */
    private array $lastError = [];


    /**
     * Retrieves the current database instance. Will create a new one if there isn't an existing connection.
     *
     * @param \SimpleSAML\Configuration|null $altConfig Optional: Instance of a \SimpleSAML\Configuration class
     *
     * @return \SimpleSAML\Database The shared database connection.
     */
    public static function getInstance(?Configuration $altConfig = null): Database
    {
        $config = ($altConfig) ? $altConfig : Configuration::getInstance();
        $instanceId = self::generateInstanceId($config);

        // check if we already have initialized the session
        if (isset(self::$instance[$instanceId])) {
            return self::$instance[$instanceId];
        }

        // create a new session
        self::$instance[$instanceId] = new Database($config);
        return self::$instance[$instanceId];
    }


    /**
     * Private constructor that restricts instantiation to getInstance().
     *
     * @param \SimpleSAML\Configuration $config Instance of the \SimpleSAML\Configuration class
     */
    private function __construct(Configuration $config)
    {
        $driverOptions = $config->getOptionalArray('database.driver_options', []);
        if ($config->getOptionalBoolean('database.persistent', true)) {
            $driverOptions[PDO::ATTR_PERSISTENT] = true;
        }

        // connect to the primary
        $this->dbPrimary = $this->connect(
            $config->getString('database.dsn'),
            $config->getOptionalString('database.username', null),
            $config->getOptionalString('database.password', null),
            $driverOptions,
        );

        // TODO: deprecated: the "database.slave" terminology is preserved here for backwards compatibility.
        if ($config->getOptionalArray('database.slaves', null) !== null) {
            Logger::warning(
                'The "database.slaves" config option is deprecated. ' .
                'Please update your configuration to use "database.secondaries".',
            );
        }
        // connect to any configured secondaries, preserving legacy config option
        $secondaries = $config->getOptionalArray(
            'database.secondaries',
            $config->getOptionalArray('database.slaves', []),
        );
        foreach ($secondaries as $secondary) {
            array_push(
                $this->dbSecondaries,
                $this->connect(
                    $secondary['dsn'],
                    $secondary['username'],
                    $secondary['password'],
                    $driverOptions,
                ),
            );
        }
        $this->tablePrefix = $config->getOptionalString('database.prefix', '');
    }


    /**
     * Generate an Instance ID based on the database configuration.
     *
     * @param \SimpleSAML\Configuration $config Configuration class
     *
     * @return string $instanceId
     */
    private static function generateInstanceId(Configuration $config): string
    {
        $assembledConfig = [
            'primary' => [
                'database.dsn'        => $config->getString('database.dsn'),
                'database.username'   => $config->getOptionalString('database.username', null),
                'database.password'   => $config->getOptionalString('database.password', null),
                'database.prefix'     => $config->getOptionalString('database.prefix', ''),
                'database.persistent' => $config->getOptionalBoolean('database.persistent', true),
            ],

            // TODO: deprecated: the "database.slave" terminology is preserved here for backwards compatibility.
            'secondaries' => $config->getOptionalArray(
                'database.secondaries',
                $config->getOptionalArray('database.slaves', []),
            ),
        ];

        return sha1(serialize($assembledConfig));
    }


    /**
     * This function connects to a database.
     *
     * @param string $dsn Database connection string
     * @param string|null $username SQL user
     * @param string|null $password SQL password
     * @param array $options PDO options
     *
     * @throws \Exception If an error happens while trying to connect to the database.
     * @return \PDO object
     */
    private function connect(string $dsn, ?string $username = null, ?string $password = null, array $options = []): PDO
    {
        try {
            $db = new PDO($dsn, $username, $password, $options);
            $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
            $db->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);

            return $db;
        } catch (PDOException $e) {
            throw new Exception("Database error: " . $e->getMessage());
        }
    }


    /**
     * This function randomly selects a secondary database server to query. In the event no secondaries are configured,
     * it will return the primary.
     *
     * @return \PDO object
     */
    private function getSecondary(): PDO
    {
        if (count($this->dbSecondaries) > 0) {
            $secondaryId = rand(0, count($this->dbSecondaries) - 1);
            return $this->dbSecondaries[$secondaryId];
        } else {
            return $this->dbPrimary;
        }
    }


    /**
     * This function simply applies the table prefix to a supplied table name.
     *
     * @param string $table Table to apply prefix to, if configured
     *
     * @return string Table with configured prefix
     */
    public function applyPrefix(string $table): string
    {
        return $this->tablePrefix . $table;
    }


    /**
     * This function queries the database
     *
     * @param \PDO   $db PDO object to use
     * @param string $stmt Prepared SQL statement
     * @param array  $params Parameters
     *
     * @throws \Exception If an error happens while trying to execute the query.
     * @return \PDOStatement object
     */
    private function query(PDO $db, string $stmt, array $params): PDOStatement
    {
        try {
            $query = $db->prepare($stmt);

            foreach ($params as $param => $value) {
                if (is_array($value)) {
                    $query->bindValue(":$param", $value[0], ($value[1]) ? $value[1] : PDO::PARAM_STR);
                } else {
                    $query->bindValue(":$param", $value, PDO::PARAM_STR);
                }
            }

            $query->execute();

            return $query;
        } catch (PDOException $e) {
            $this->lastError = $db->errorInfo();
            throw new Exception("Database error: " . $e->getMessage());
        }
    }


    /**
     * This function queries the database without using a prepared statement.
     *
     * @param \PDO   $db PDO object to use
     * @param string $stmt An SQL statement to execute, previously escaped.
     *
     * @throws \Exception If an error happens while trying to execute the query.
     * @return int The number of rows affected.
     */
    private function exec(PDO $db, string $stmt): int
    {
        try {
            return $db->exec($stmt);
        } catch (PDOException $e) {
            $this->lastError = $db->errorInfo();
            throw new Exception("Database error: " . $e->getMessage());
        }
    }


    /**
     * This executes queries directly on the primary.
     *
     * @param string $stmt Prepared SQL statement
     * @param array  $params Parameters
     *
     * @return int|false The number of rows affected by the query or false on error.
     */
    public function write(string $stmt, array $params = []): int|bool
    {
        return $this->query($this->dbPrimary, $stmt, $params)->rowCount();
    }


    /**
     * This executes queries on a database server that is determined by this::getSecondary().
     *
     * @param string $stmt Prepared SQL statement
     * @param array  $params Parameters
     *
     * @return \PDOStatement object
     */
    public function read(string $stmt, array $params = []): PDOStatement
    {
        $db = $this->getSecondary();

        return $this->query($db, $stmt, $params);
    }


    /**
     * Return an array with information about the last operation performed in the database.
     *
     * @return array The array with error information.
     */
    public function getLastError(): array
    {
        return $this->lastError;
    }


    /**
     * Return the name of the PDO-driver
     *
     * @return string
     */
    public function getDriver(): string
    {
        return $this->dbPrimary->getAttribute(PDO::ATTR_DRIVER_NAME);
    }
}
