<?php
declare(strict_types = 1);

// Chargement de la classe DBLayer.
require_once(__DIR__ . '/dblayer/' . CONF_DB_TYPE . '.class.php');



/**
 * Gestionnaire de base de données.
 *
 * - TOUTES les requêtes SQL doivent être écrites au format MySQL. La classe
 *   DBLayer de chaque SGBD se chargera de convertir les parties de la
 *   requête qui doivent l'être dans son langage propre.
 *   Par exemple, en écrivant "INSERT IGNORE ..." (format MySQL), la classe
 *   DBLayer de SQLite traduira cela en "INSERT OR IGNORE ...".
 *
 * - NE JAMAIS utiliser les méthodes lastInsertId() ou allInsertId() après
 *   une requête "INSERT OR IGNORE ...", car les identifiants retournés ne
 *   sont pas les mêmes entre les différents SGBD selon qu'une ligne a été
 *   insérée ou ignorée.
 *
 * - NE JAMAIS utiliser la méthode rowCount() après une requête "SELECT ...",
 *   car la méthode retournera toujours 0. A la place, il faut utiliser la
 *   fonction SQL "COUNT()", ou bien utiliser count(DB::fetchAll()).
 *
 * - NE JAMAIS indiquer l'ID d'une colonne AUTO_INCREMENT dans une requête
 *   "INSERT ...", car sinon (avec PostgreSQL) lors d'une prochaine requête
 *   "INSERT ..." sans avoir spécifié l'ID, celui-ci commencera à 1.
 *
 * - NE JAMAIS utiliser la valeur "0000-00-00 00:00:00" pour les colonnes
 *   de type DATETIME, car certains SGBD ne l'acceptent pas.
 *
 * - TOUJOURS passer un tableau ['table' => '{table}', 'column' => 'column']
 *   comme troisième paramètre ($seq) de la méthode execute() si l'objectif
 *   est d'utiliser la méthode lastInsertId() ou allInsertId() après une
 *   requête "INSERT ...". Ce tableau correspond aux paramètres de la
 *   séquence utilisée par PostgreSQL sur les colonnes de type SERIAL
 *   (à la place d'AUTO_INCREMENT).
 *
 * @license https://www.gnu.org/licenses/gpl-3.0.html
 * @link https://www.igalerie.org/
 */
class DB extends DBLayer
{
	/**
	 * Objet PDO.
	 *
	 * @var PDO
	 */
	public static $PDO;

	/**
	 * Objet PDOStatement.
	 *
	 * @var PDOStatement
	 */
	public static $PDOStatement;

	/**
	 * Tableau regroupant les informations de toutes les requêtes exécutées.
	 *
	 * @var array
	 */
	public static $queries = [];

	/**
	 * Version de la base de données.
	 *
	 * @var string
	 */
	public static $version = '';



	/**
	 * Identifiants de toutes les lignes insérées.
	 *
	 * @var array
	 */
	private static $_allInsertId = [];

	/**
	 * Paramètres des requêtes préparées.
	 *
	 * @var array
	 */
	private static $_params = [];

	/**
	 * Nombre de lignes affectées par la requête.
	 *
	 * @var int
	 */
	private static $_rowCount = 0;

	/**
	 * Nom de la séquence d'objets utilisée par la méthode lastInsertId().
	 *
	 * @var string
	 */
	private static $_seqName;

	/**
	 * Requête SQL venant d'être exécutée.
	 *
	 * @var string
	 */
	private static $_sql;

	/**
	 * Timestamp du début de la requête.
	 *
	 * @var int
	 */
	private static $_time;



	/**
	 * Retourne les identifiants de toutes les lignes insérées.
	 *
	 * @return array
	 */
	public static function allInsertId(): array
	{
		return self::$_allInsertId;
	}

	/**
	 * Démarre une transaction.
	 *
	 * @return bool
	 */
	public static function beginTransaction(): bool
	{
		if (!CONF_DB_TRAN)
		{
			return TRUE;
		}

		self::_init('START TRANSACTION');

		try
		{
			self::$PDO->beginTransaction();
			self::_debug();

			return TRUE;
		}
		catch (PDOException $e)
		{
			self::_exception($e);

			return FALSE;
		}
	}

	/**
	 * Valide une transaction.
	 *
	 * @return bool
	 */
	public static function commitTransaction(): bool
	{
		if (!CONF_DB_TRAN)
		{
			return TRUE;
		}

		self::_init('COMMIT TRANSACTION');

		try
		{
			self::$PDO->commit();
			self::_debug();

			return TRUE;
		}
		catch (PDOException $e)
		{
			self::_exception($e);

			return FALSE;
		}
	}

	/**
	 * Connexion à la base de données.
	 *
	 * @return bool
	 */
	public static function connect(): bool
	{
		if (self::$PDO !== NULL)
		{
			return TRUE;
		}

		try
		{
			$pdo = version_compare(PHP_VERSION, '8.4', '<')
				? 'PDO'
				: 'Pdo\\' . ucfirst(CONF_DB_TYPE);
			self::$PDO = new $pdo(parent::_getDSN(), CONF_DB_USER, CONF_DB_PASS);

			self::$version = self::$PDO->getAttribute(PDO::ATTR_SERVER_VERSION);
			self::$PDO->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
			self::$PDO->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);

			if (!parent::_initialize(self::$PDO))
			{
				throw new PDOException('Initialization failed.');
			}

			return TRUE;
		}
		catch (PDOException $e)
		{
			self::_exception($e);
			self::disconnect();

			return FALSE;
		}
	}

	/**
	 * Fermeture de la connexion à la base de données.
	 *
	 * @return void
	 */
	public static function disconnect(): void
	{
		self::$PDO = NULL;
	}

	/**
	 * Prépare et exécute une requête SQL.
	 *
	 * @param string $sql
	 *   Requête SQL.
	 * @param mixed $params
	 *   Le ou les paramètre(s) de la requête préparée.
	 * @param array $seq
	 *   Nom de la séquence d'objets (pour PostgreSQL).
	 * @param array $fetch
	 *   Tableau des données à récupérer dans le cas d'une requête
	 *   préparée multiple en SELECT à l'aide d'une méthode à spécifier.
	 *   Exemples :
	 *      $fetch = ['fetchAll'];
	 *      $fetch = ['fetchAll', 'col1', 'col2'];
	 *   Après l'utilisation de execute(), le tableau $fetch
	 *   contiendra les données récupérées avec la méthode fetchAll.
	 * @param bool $fetch_not_null
	 *   Dans le cas de l'utilisation de $fetch, détermine
	 *   s'il faut retourner les résultats vides.
	 *
	 * @return bool
	 */
	public static function execute(string $sql,
	$params = [], array $seq = [], array &$fetch = [], bool $fetch_not_null = FALSE): bool
	{
		// Nettoyage du code.
		if (class_exists('Utility'))
		{
			$sql = Utility::deleteInvisibleChars($sql);
			$sql = Utility::trimAll($sql);
		}

		// Caractères non autorisés.
		// On remplace les guillemets doubles par des guillemets simples car sinon
		// PostgreSQL va générer une erreur du type "la colonne « x » n'existe pas"
		// si on écrit quelque chose de ce genre :
		// SELECT conf_value FROM {config} WHERE conf_name = "gallery_title"
		$sql = str_replace(['"', '$', ';'], ["'", '', ''], $sql);

		// Remplacement de code SQL.
		$sql = parent::_replaceSQL($sql);

		// Ajout du préfixe de tables.
		$sql = self::_replaceTableName($sql);

		self::_init($sql);

		// Nom de la séquence d'objets.
		if (isset($seq['table']))
		{
			$seq['table'] = self::_replaceTableName($seq['table']);
			self::$_seqName = parent::_seqName($seq);
		}

		// Paramètres.
		if (self::$_params && $params === [])
		{
			$params = self::$_params;
			self::$_params = [];
		}
		if (!is_array($params))
		{
			$params = [[$params]];
		}
		else if ((array_key_exists(0, $params) && is_array($params[0])) === FALSE)
		{
			$params = [$params];
		}

		// Vérification du nombre de paramètres.
		if (CONF_DEBUG_MODE || CONF_DEV_MODE)
		{
			$anonym = array_key_exists(0, $params[0]);
			$count_a = count(preg_split('`\?`', $sql)) - 1;
			$count_n = preg_match_all('`[^:](:[a-z][_a-z0-9]+)`', $sql, $m)
				? count(array_flip($m[0]))
				: 0;
			$count_p = count($params[0]);
			if (($anonym && $count_a != $count_p)
			|| (!$anonym && $count_p && $count_n != $count_p)
			|| (!$count_p && ($count_a || $count_n)))
			{
				trigger_error('Wrong number of parameters.', E_USER_NOTICE);
				return FALSE;
			}
		}

		// Argument $fetch.
		$is_fetch = count($fetch);
		if ($is_fetch)
		{
			$fetch_method = $fetch[0];
			$fetch_arg_1 = $fetch[1] ?? '';
			$fetch_arg_2 = $fetch[2] ?? '';
			$fetch = [];
		}

		try
		{
			// Préparation.
			self::$PDOStatement = self::$PDO->prepare($sql);

			// Exécution.
			for ($i = 0; $i < count($params); $i++)
			{
				self::$PDOStatement->execute($params[$i]);

				if ($is_fetch)
				{
					$f = self::{$fetch_method}($fetch_arg_1, $fetch_arg_2);
					if (!$fetch_not_null || ($f !== NULL && $f !== []))
					{
						$fetch[] = $f;
					}
				}

				// Nombre de lignes affectées.
				self::$_rowCount += self::$PDOStatement->rowCount();

				// Identifiants des derniers enregistrements.
				self::$_allInsertId[] = self::lastInsertId();
			}

			self::_debug();

			return TRUE;
		}
		catch (PDOException $e)
		{
			self::_exception($e);

			return FALSE;
		}
	}

	/**
	 * Retourne toutes les données récupérées par une requête.
	 * Par défaut, c'est à dire sans aucun argument fourni, cette méthode
	 * retourne un tableau avec le style PDO::FETCH_ASSOC :
	 *
	 *   Array
	 *   (
	 * 	    [0] => array(...)
	 * 	    [1] => array(...)
	 * 	    ...
	 *   )
	 *
	 * Pour créer un tableau dont les données seront indexées sur la
	 * valeur d'une colonne, il faut passer le nom de la colonne dans
	 * l'argument $col1 :
	 *
	 *   Array
	 *   (
	 * 	    [valeur de $col1] => array(...)
	 * 	    [valeur de $col1] => array(...)
	 * 	    ...
	 *   )
	 *
	 * Le second argument $col2 sert à faire la même chose que précédemment,
	 * mais avec uniquement la valeur de $col2 associée à celle de $col1 :
	 *
	 *   Array
	 *   (
	 * 	    [valeur de $col1] => valeur de $col2
	 * 	    [valeur de $col1] => valeur de $col2
	 * 	    ...
	 *   )
	 *
	 * @param string $col1
	 *   Nom de la première colonne.
	 * @param string $col2
	 *   Nom de la seconde colonne.
	 *
	 * @return array
	 */
	public static function fetchAll(string $col1 = '', string $col2 = ''): array
	{
		if ($col1 === '')
		{
			return self::$PDOStatement->fetchAll(PDO::FETCH_ASSOC);
		}

		$result = [];
		while ($row = self::$PDOStatement->fetch())
		{
			$result[$row[$col1] === NULL ? '' : $row[$col1]] = $col2 === '' ? $row : $row[$col2];
		}

		return $result;
	}

	/**
	 * Retourne les données d'une colonne sous la forme suivante :
	 *
	 *   Array
	 *   (
	 * 	    [0] => valeur de $col
	 * 	    [1] => valeur de $col
	 * 	    ...
	 *   )
	 *
	 * @param string $col
	 *   Nom de la colonne.
	 *
	 * @return array
	 */
	public static function fetchCol(string $col): array
	{
		return array_values(self::fetchAll($col, $col));
	}

	/**
	 * Retourne les données d'une ligne sous la forme suivante :
	 *
	 *   Array
	 *   (
	 * 	    [nom de la première colonne] => valeur de la colonne
	 * 	    [nom de la deuxième colonne] => valeur de la colonne
	 * 	    ...
	 *   )
	 *
	 * @return array
	 */
	public static function fetchRow(): array
	{
		$row = self::$PDOStatement->fetch();
		return $row === FALSE ? [] : $row;
	}

	/**
	 * Retourne une donnée.
	 *
	 * @return mixed
	 */
	public static function fetchVal()
	{
		$val = self::$PDOStatement->fetch(PDO::FETCH_NUM);
		return $val === FALSE ? NULL : $val[0];
	}

	/**
	 * Retourne la dernière erreur survenue.
	 *
	 * @return string
	 */
	public static function getError(): string
	{
		return isset(self::$queries[count(self::$queries) - 1]['exception'])
			? self::$queries[count(self::$queries) - 1]['exception']->getMessage()
			: '';
	}

	/**
	 * Convertit les valeurs d'un tableau en entier
	 * à destination d'une clause IN.
	 *
	 * @param array $arr
	 *
	 * @return string
	 */
	public static function inInt(array $arr): string
	{
		if (!count($arr))
		{
			trigger_error('Empty array.', E_USER_NOTICE);
			return ')';
		}
		return implode(', ', array_unique(array_map('intval', $arr)));
	}

	/**
	 * Convertit les valeurs d'un tableau en marqueurs de
	 * requête préparée à destination d'une clause IN.
	 *
	 * @param array $arr
	 *
	 * @return string
	 */
	public static function inParams(array $arr): string
	{
		if (!count($arr))
		{
			trigger_error('Empty array.', E_USER_NOTICE);
			return ')';
		}
		return implode(', ', array_map(function(){return'?';}, $arr));
	}

	/**
	 * Convertit les valeurs d'un tableau en chaîne
	 * à destination d'une clause IN.
	 *
	 * @param array $arr
	 *
	 * @return string
	 */
	public static function inStr(array $arr): string
	{
		if (!count($arr))
		{
			trigger_error('Empty array.', E_USER_NOTICE);
			return ')';
		}
		return implode(', ', array_unique(array_map(function($v){return"'$v'";}, $arr)));
	}

	/**
	 * Permet de savoir si on se trouve à l'intérieur d'une transaction,
	 * c'est à dire si une transaction a été démarrée, mais qu'elle n'a
	 * été ni validée ni annulée.
	 *
	 * @return bool
	 */
	public static function inTransaction(): bool
	{
		return self::$PDO->inTransaction();
	}

	/**
	 * Retourne l'identifiant de la dernière ligne insérée.
	 *
	 * @return int
	 */
	public static function lastInsertId(): int
	{
		if (CONF_DB_TYPE == 'pgsql' && self::$_seqName === NULL)
		{
			return 0;
		}
		return (int) self::$PDO->lastInsertId(self::$_seqName);
	}

	/**
	 * Échappe les caractères _ et % dans les chaînes
	 * qui seront utilisées avec l'opérateur LIKE.
	 *
	 * @param string $str
	 *
	 * @return string
	 */
	public static function likeEscape(string $str): string
	{
		return str_replace(['_', '%'], ['\_', '\%'], $str);
	}

	/**
	 * Ajoute des paramètres nommés (uniquement) pour une requête préparée.
	 *
	 * @param array $params
	 *
	 * @return void
	 */
	public static function params(array $params): void
	{
		if (array_key_exists(0, $params))
		{
			trigger_error('You should only use named parameters.', E_USER_NOTICE);
			return;
		}
		self::$_params = array_merge(self::$_params, $params);
	}

	/**
	 * Si active, annule une transaction en cours,
	 * puis retourne l'argument $r.
	 *
	 * @param mixed $r
	 *
	 * @return mixed
	 */
	public static function rollback($r = NULL)
	{
		if (self::inTransaction())
		{
			self::rollbackTransaction();
		}
		return $r;
	}

	/**
	 * Annule une transaction.
	 *
	 * @return bool
	 */
	public static function rollbackTransaction(): bool
	{
		if (!CONF_DB_TRAN)
		{
			return TRUE;
		}

		self::_init('ROLLBACK TRANSACTION');

		try
		{
			self::$PDO->rollback();
			self::_debug();

			return TRUE;
		}
		catch (PDOException $e)
		{
			self::_exception($e);

			return FALSE;
		}
	}

	/**
	 * Retourne le nombre de lignes affectées.
	 * Ne jamais utiliser cette méthode après une requête 'SELECT',
	 * car certaines bases de données comme SQLITE retourneront toujours 0...
	 * Cette méthode fonctionne donc de la même manière.
	 *
	 * @param bool $debug
	 *
	 * @return int
	 */
	public static function rowCount(bool $debug = FALSE): int
	{
		// Imitation du fonctionnement de SQLITE.
		if (strtolower(substr(trim((string) self::$_sql), 0, 6)) == 'select')
		{
			// Pour débogage.
			if ($debug)
			{
				return CONF_DB_TYPE == 'sqlite' ? -1 : self::$_rowCount;
			}

			trigger_error('Do not use rowCount() after SELECT.', E_USER_NOTICE);
			return 0;
		}

		return self::$_rowCount;
	}



	/**
	 * Informations de débogage enregistrées à la fin de l'exécution d'une requête.
	 *
	 * @param PDOException $e
	 *
	 * @return void
	 */
	private static function _debug(?PDOException $e = NULL): void
	{
		self::$queries[] =
		[
			'exception' => $e,
			'file' => debug_backtrace()[2 - (int) !$e]['file'],
			'line' => debug_backtrace()[2 - (int) !$e]['line'],
			'row_count' => self::rowCount(TRUE),
			'sql' => self::$_sql,
			'time' => microtime(TRUE) - self::$_time
		];
	}

	/**
	 * Gestion des erreurs.
	 *
	 * @param PDOException $e
	 *
	 * @return void
	 */
	private static function _exception(PDOException $e): void
	{
		self::_debug($e);

		if (class_exists('ErrorHandler'))
		{
			ErrorHandler::dbError($e);
		}
	}

	/**
	 * Initialisation des paramètres pour chaque requête/transaction.
	 *
	 * @param string $sql
	 *   Requête SQL.
	 *
	 * @return void
	 */
	private static function _init(string $sql): void
	{
		self::$_allInsertId = [];
		self::$_rowCount = 0;
		self::$_seqName = NULL;
		self::$_sql = $sql;
		self::$_time = microtime(TRUE);
	}

	/**
	 * Ajoute un préfixe aux tables suivant la syntaxe :
	 * {tablename} => prefix_tablename
	 *
	 * @param string $sql
	 *
	 * @return string
	 */
	private static function _replaceTableName(string $sql): string
	{
		return preg_replace('`\{([_a-z0-9]{1,30})\}`', CONF_DB_PREF . '$1', $sql);
	}
}
?>