<?php

include_once "DatabaseException.php";
include_once "ErrorHandler.php";

/**
 * La classe DatabaseHelper set à encapsuler les appels à la base de données.
 *
 * Les erreurs produites peuvent soit lever une exception, soit renvoyer un code d'erreur.
 * En revanche aucun traitement des erreurs (log ..) n'est fait à ce niveau.
 */
class DatabaseHelper
{
	private $link = NULL;
	private $debug = true;
	/*
	private $databaseHost = "sql1.w4a.fr";
	private $databaseName = "127670_gamedata";
	private $databaseUser = "127670_lotus";
	private $databasePass = "elan_moose_orignal";
	*/
	private $databaseHost = "localhost";
	private $databaseName = "demojs";
	private $databaseUser = "apache";
	private $databasePass = "comanche";
	private $maxMode = 2;
	private $maxCar = 12;
	
	public function __construct()
	{
	}
	
	/**
	 * Ouvre la connexion avec la base de données
	 */
	public function open() 
	{
		if ($this->link != null) {
			return; 
		}
		try 
		{
			$this->link = new PDO('mysql:dbname='.$this->databaseName.';host='.$this->databaseHost, $this->databaseUser, $this->databasePass);
		}
		catch (Exception $e) 
		{
			$this->debugWrite ("in open() : ".$e->getMessage());
			throw new DatabaseException ($e->getMessage(), FATAL_UNABLE_TO_CONNECT);
		}		
	}

	/**
	 * Récupère l'id, hash du mot de passe, chaîne de salage et état de l'utilisateur
	 * dont le nom est passé en paramètre.
	 * Le tout est renvoyé en résultat sous la forme d'un tableau dont les composants sont validés.
	 * Renvoie un tableau vide si l'utilisateur est inconnu
	 *
	 * La chaîne est supposée validée en amont.
	 */
	public function getUserCredentials($username)
	{
		$query = $this->link->prepare("SELECT ID, PW, SALT, STATUS, OPTIONS FROM users WHERE NAME = :name");
		$query->bindValue(":name", $username, PDO::PARAM_STR);
		$query->execute();
		
		$result = array();
		
		$output = $query->fetch(PDO::FETCH_ASSOC);
		if (FALSE === $output) {
			$query->closeCursor();
			throw new DatabaseException("users", FATAL_UNKNOWN_USER);
		}

		$id = intval($output["ID"]);
		$pw = $output["PW"];
		$salt = $output["SALT"];
		$status = intval($output["STATUS"]);
		$options = intval($output["OPTIONS"]);
		$singleResult = (FALSE === $query->fetch(PDO::FETCH_ASSOC));
		$query->closeCursor();
		
		if ($id<0 || $status<0 || $status>$this->maxMode) {
			throw new DatabaseException("users", FATAL_INCORRECT_DATABASE_CONTENTS);
		}
		if (!$singleResult) {
			// on n'accepte que s'il y a un seul résultat
			throw new DatabaseException("users", FATAL_DUPLICATE_USER_ON_LOGIN);
		}

		$result["ID"] = $id;
		$result["PW"] = $pw;
		$result["SALT"] = $salt;
		$result["STATUS"] = $status;
		$result["OPTIONS"] = $options;
		return $result;
	}
	
	/**
	 * Crée un compte utilisateur à partir du nom utilisateur fourni
	 * Renvoie une exception si le nom existe déja
	 */
	public function createUser($username, $address, $salt, $pwHash, $status)
	{
		// Création dans la table users
		$query = $this->link->prepare("INSERT INTO users (NAME, ADDRESS, PW, SALT, STATUS) VALUES (:name, :address, :pw, :salt, :status)");
		$query->bindValue(":name", $username, PDO::PARAM_STR);
		$query->bindValue(":address", $address, PDO::PARAM_STR);
		$query->bindValue(":pw", $pwHash, PDO::PARAM_STR);
		$query->bindValue(":salt", $salt, PDO::PARAM_STR);
		$query->bindValue(":status", $status, PDO::PARAM_INT);
		$result = $query->execute();
		$query->closeCursor();
		if (!$result) {
			// Seule raison possible : le nom est déja pris
			$this->debugWrite($query->errorInfo[0]." ".$query->errorInfo[1]." ".$query->errorInfo[2]);
			throw new DatabaseException("users", FATAL_DUPLICATE_USER_ON_SIGNUP);
		}
		
		// Création dans la table career
		$careerQuery = $this->link->prepare("INSERT INTO career (ID) VALUES (:id)");
		$id = (int)($this->link->lastInsertId());
		$careerQuery->bindValue(":id", $id, PDO::PARAM_INT);
		$result = $careerQuery->execute();
		$careerQuery->closeCursor();
		if (!$result) {
			$this->debugWrite($careerQuery->errorInfo[0]." ".$careerQuery->errorInfo[1]." ".$careerQuery->errorInfo[2]);
			throw new DatabaseException("users", FATAL_UNABLE_TO_INSERT_IN_CAREER);
		}
	}
	
	/**
	 * Modifie le mot de passe d'un utilisateur (sur action explicite utilisateur logué)
	 * uniquement si l'ancien mot de passe correspond. 
	 * Renvoie TRUE si le changement a été effectué, FALSE sinon.
	 */
	public function alterPassword($id, $salt, $oldPwHash, $newPwHash)
	{
		// tout est dans la même requête : la clause WHERE filtre l'id et l'ancien mot de passe.
		// Si tout correspond, le UPDATE se charge de mettre à jour les infos
		$query = $this->link->prepare('UPDATE users SET SALT = :salt, PW = :pw WHERE ID = :id AND PW = :oldPw');
		$query->bindValue(":salt", $salt, PDO::PARAM_STR);
		$query->bindValue(":pw", $newPwHash, PDO::PARAM_STR);
		$query->bindValue(":id", $id, PDO::PARAM_INT);
		$query->bindValue(":oldPw", $oldPwHash, PDO::PARAM_STR);
		$query->execute();
		$output = $query->fetch(PDO::FETCH_ASSOC);
		$query->closeCursor();
		if (FALSE === $output) {
			return FALSE;
			// TODO : identifier les causes d'erreur et renvoyer le code correspondant
		}
		return TRUE;
	}
	
	/**
	 * Crée un nouveau mot de passe pour un utilisateur à partir de son login et adresse mail
	 * (fonction "oubli de mot de passe"). 
	 * Renvoie TRUE si le changement a été effectué, FALSE sinon.
	 */
	public function resetPassword($username, $address, $salt, $newPwHash)
	{
		// tout est dans la même requête : la clause WHERE filtre le nom et l'adresse
		// Si tout correspond, le UPDATE se charge de mettre à jour les infos
		$query = $this->link->prepare('UPDATE users SET SALT = :salt, PW = :pw WHERE NAME = :username AND ADDRESS = :address');
		$query->bindValue(":salt", $salt, PDO::PARAM_STR);
		$query->bindValue(":pw", $newPwHash, PDO::PARAM_STR);
		$query->bindValue(":username", $username, PDO::PARAM_STR);
		$query->bindValue(":address", $address, PDO::PARAM_STR);
		$query->execute();
		$output = $query->fetch(PDO::FETCH_ASSOC);
		$query->closeCursor();
		if (FALSE === $output) {
			return FALSE;
			// TODO : identifier les causes d'erreur et renvoyer le code correspondant
		}
		return TRUE;
	}
	
	/**
	 * Modifie le statut (verrouillé, actif, en attente de confirmation, ..)
	 * d'un ensemble de comptes identifiés par leur ID
	 *  - $userIDs : tableau contenant les ID concernés
	 *  - $newStatus : nouvelle valeur du statut (0 à 3, voir les docs de conception)
	 * Renvoie SUCCESS en cas de réussite, ou un code d'erreur associé
	 */
	public function changeAccountStatus ($userIDs, $newStatus)
	{
		$queryText = 'UPDATE users SET STATUS = :status WHERE ID = :id0';
		$idCount = count($userIDs);
		if ($idCount < 1) {
			ErrorHandler::getInstance()->setParameters("Aucun compte à modifier.");
			return WARN_NO_OPERATION;
		}
		for ($index=1; $index<$idCount; ++$index) {
			$queryText.= ' OR ID = :id'.$index;
		}
		$query = $this->link->prepare($queryText);
		$query->bindValue(":status", $newStatus, PDO::PARAM_INT);
		foreach($userIDs as $index => $id)
		{
			$query->bindValue(":id".$index, $id, PDO::PARAM_INT);
		}
		$query->execute();
		$errorDetails = $query->errorInfo();
		$query->closeCursor();
		if (strcmp('00000', $errorDetails[0]) != 0) {
			ErrorHandler::getInstance()->setParameters($errorDetails[0].' : '.$errorDetails[2]);
			return ERROR_GENERIC_DB_ERROR;
		}
		return SUCCESS;
	}
	
	/**
	 * Valide un compte utilisateur (passe le flag STATUS à 0) si la chaîne de validation
	 * est identique à celle envoyée par mail
	 */
	public function validateAccount($username, $validation)
	{
		
	}
	
	/**
	 * Enregistre les options pour l'utilisateur concerné
	 **/
	public function setUserOptions($userId, $options)
	{
		$queryText = 'UPDATE users SET OPTIONS = :options WHERE ID = :id';
		$query = $this->link->prepare($queryText);
		$query->bindValue(":options", $options, PDO::PARAM_INT);
		$query->bindValue(":id", $userId, PDO::PARAM_INT);
		$query->execute();
		$errorDetails = $query->errorInfo();
		$query->closeCursor();
		if (strcmp('00000', $errorDetails[0]) != 0) {
			ErrorHandler::getInstance()->setParameters($errorDetails[0].' : '.$errorDetails[2]);
			return ERROR_GENERIC_DB_ERROR;
		}
		return SUCCESS;
	}
	
	/**
	 * Récupère les différents paramètres de progression de l'utilisateur
	 *  - circuit atteint
	 *  - voitures déverrouillées
	 *  - modes de conduite disponibles
	 */
	public function getUserProgress($userId)
	{
		$selectQuery = $this->link->prepare('SELECT LEVEL, CAR_MASK, MODE_MASK FROM career WHERE ID = :id LIMIT 1');
		$selectQuery->bindValue(":id", $userId, PDO::PARAM_STR);
		$selectQuery->execute();
		
		// valeurs par défaut si le joueur n'est pas identifié
		$result = array ('LEVEL' => 1, 'CAR_MASK' => 3, 'MODE_MASK' => 2);
		$output = $selectQuery->fetch(PDO::FETCH_ASSOC);
		if (FALSE !== $output) {
			$result['LEVEL'] = intval($output['LEVEL']);
			$result['CAR_MASK'] = intval($output['CAR_MASK']);
			$result['MODE_MASK'] = intval($output['MODE_MASK']);
		}
		$selectQuery->closeCursor();
		return $result;
	}
	
	
	/**
	 * Vérifie que le joueur a bien accès au circuit, au mode et à la voiture demandés
	 * Si oui, positionne les valeurs "courantes" de ces trois paramètres dans la BDD
	 * et renvoie SUCCESS. Sinon, laisse la base inchangée et renvoie le code d'erreur approprié.
	 */
	public function authorizeRace($id, $raceIndex, $modeIndex, $carIndex)
	{
		// Séparation de la requête : il était possible de tout faire en un UPDATE .. WHERE
		// qui vérifiait que les course/mode/voiture étaient accessibles au joueur et mettait
		// à jour les champs CURRENT, mais en sortie on ne distinguait pas entre
		// le cas où un des éléments était verrouillé (donc pas de mise à jour car le filtrage
		// par la clause WHERE renvoyait zéro réponse) et celui où le joueur demandait deux 
		// fois de suite les mêmes paramètres (le WHERE renvoyait bien une ligne mais le UPDATE
		// ne modifiait pas les champs CURRENT qui avaient déja la bonne valeur, et au final la
		// requête renvoyait zéro ligne modifiée).
		$selectQuery = $this->link->prepare('SELECT users.ID FROM users, career WHERE users.ID = :id AND career.ID = :id AND STATUS = 0 AND LEVEL >= :race AND (MODE_MASK & (1 << (:mode - 1))) > 0 AND (CAR_MASK & (1 << (:car - 1))) > 0 LIMIT 1');
		$selectQuery->bindValue(":id", $id, PDO::PARAM_STR);
		$selectQuery->bindValue(":race", $raceIndex, PDO::PARAM_INT);
		$selectQuery->bindValue(":mode", $modeIndex, PDO::PARAM_INT);
		$selectQuery->bindValue(":car", $carIndex, PDO::PARAM_INT);
		$selectQuery->execute();
		$output = $selectQuery->fetch(PDO::FETCH_ASSOC);
		$selectQuery->closeCursor();
		
		if (FALSE === $output) {
			return ERROR_LOCKED_ITEM;
		}
	
		// Si tout correspond, le UPDATE se charge de mettre à jour les infos
		$updateQuery = $this->link->prepare('UPDATE career SET CURRENT_RACE = :race, CURRENT_MODE = :mode, CURRENT_CAR = :car WHERE ID = :id LIMIT 1');
		$updateQuery->bindValue(":id", $id, PDO::PARAM_STR);
		$updateQuery->bindValue(":race", $raceIndex, PDO::PARAM_INT);
		$updateQuery->bindValue(":mode", $modeIndex, PDO::PARAM_INT);
		$updateQuery->bindValue(":car", $carIndex, PDO::PARAM_INT);
		$updateQuery->execute();
		$updateQuery->closeCursor();
		return SUCCESS;
	}
	
	/**
	 * Vérifie que le temps et la télémétrie renvoyée correspondent bien au dernier circuit demandé
	 * Contrôle également que le temps est meilleur que le précédent du joueur. Si oui, enregistre
	 * le tout dans la base, et met à jour le cache des 5 meilleurs temps pour ce circuit, et renvoie
	 * SUCCESS. Sinon, renvoie un code d'erreur sans modifier la base.
	 *
	 * Les paramètres doivent avoir été validés en amont.
	 */
	public function approveLapRecord($id, $raceIndex, $modeIndex, $carIndex, $lapTime, $ghost)
	{
		// on vérifie que les valeurs fournies correspondent à ce qui a été réglé lors du lancement de la course
		$selectQuery = $this->link->prepare('SELECT NAME FROM users, career WHERE users.ID = :id AND career.ID = :id AND CURRENT_RACE = :race AND CURRENT_MODE = :mode AND CURRENT_CAR = :car');
		$selectQuery->bindValue(":id", $id, PDO::PARAM_STR);
		$selectQuery->bindValue(":race", $raceIndex, PDO::PARAM_INT);
		$selectQuery->bindValue(":mode", $modeIndex, PDO::PARAM_INT);
		$selectQuery->bindValue(":car", $carIndex, PDO::PARAM_INT);
		$selectQuery->execute();
		$output = $selectQuery->fetch(PDO::FETCH_ASSOC);
		if (FALSE === $output) {
			$selectQuery->closeCursor();
			// pas de réponse à cette combinaison ID de joueur / course / mode / voiture : on rejette
			return ERROR_RACE_MISMATCH;
		}

		$username = $output['NAME'];
		$selectQuery->closeCursor();

		// on vérifie que le joueur n'a pas fait de copier-coller d'une autre télémétrie (autre joueur ou autre mode)
		// la vérification porte sur TIME et CRC qui seront identiques, et sont des index de la base
		// en cas d'égalité, on compare GHOST qui est une chaîne, donc plus long à tester.
		$dupQuery = $this->link->prepare('SELECT ID, GHOST FROM laptimes WHERE CRC = :crc AND TIME = :time');
		$dupQuery->bindValue(":time", $lapTime, PDO::PARAM_INT);
		$dupQuery->bindValue(":crc", crc32($ghost), PDO::PARAM_INT);
		$dupQuery->execute();
		$output = $selectQuery->fetch(PDO::FETCH_ASSOC);
		while ($output !== FALSE) {
			if ($output["GHOST"] == $ghost) {
				// on a une copie exacte
				return ERROR_GHOST_DUPLICATION;
			}
			// même temps et crc mais contenu différent ... juste une coincidence, on continue
			$output = $selectQuery->fetch(PDO::FETCH_ASSOC);
		}
		
		// A ce point, on accepte de rentrer le nouveau record dans la base
		// (sauf un cas : le joueur a déja mieux)
		$output = $this->getPlayerLapRecord($id, $raceIndex, $modeIndex);
		$previousTime = $output['TIME'];
		
		$updateQueryText = "INSERT INTO laptimes (ID, RACE, MODE, CAR, TIME, GHOST, CRC) VALUES (:id, :race, :mode, :car, :time, :ghost, :crc)";
		$updateQuery = $this->link->prepare($updateQueryText);
		$updateQuery->bindValue(":id", $id, PDO::PARAM_INT);
		$updateQuery->bindValue(":race", $raceIndex, PDO::PARAM_INT);
		$updateQuery->bindValue(":mode", $modeIndex, PDO::PARAM_INT);
		$updateQuery->bindValue(":car", $carIndex, PDO::PARAM_INT);
		$updateQuery->bindValue(":time", $lapTime, PDO::PARAM_INT);
		$updateQuery->bindValue(":ghost", $ghost, PDO::PARAM_STR);
		$updateQuery->bindValue(":crc", crc32($ghost), PDO::PARAM_INT);
		// le champ HOUR, de type TIMESTAMP, sera automatiquement renseigné sans action de notre part
		$result = $updateQuery->execute();
		// TODO : en cas d'échec, enregistrer dans le log
		$updateQuery->closeCursor();
		return SUCCESS;
	}
	
	/**
	 * Enregistre un incident dans la table ad hoc.
	 * Plusieurs cas peuvent correspondre : erreur de login, d'inscription, télémétrie renvoyée inconsistente
	 */
	public function recordIncident($id, $name, $errorCode, $errorMessage, $raceIndex, $modeIndex, $carIndex, $lapTime, $ghost)
	{
		$query = $this->link->prepare("INSERT INTO incidents (USERID, NAME, ERRORCODE, MESSAGE, RACE, MODE, CAR, TIME, GHOST) VALUES (:id, :name, :code, :message, :race, :mode, :car, :time, :ghost)");
		$query->bindValue(":id", $id, PDO::PARAM_INT);
		$query->bindValue(":name", $name, PDO::PARAM_STR);
		$query->bindValue(":code", $errorCode, PDO::PARAM_INT);
		$query->bindValue(":message", $errorMessage, PDO::PARAM_STR);
		$query->bindValue(":race", $raceIndex, PDO::PARAM_INT);
		$query->bindValue(":mode", $modeIndex, PDO::PARAM_INT);
		$query->bindValue(":car", $carIndex, PDO::PARAM_INT);
		$query->bindValue(":time", $lapTime, PDO::PARAM_INT);
		$query->bindValue(":ghost", $ghost, PDO::PARAM_STR);
		$result = $query->execute();
		// TODO : en cas d'échec de la tentative de log .. on est cuits
		$query->closeCursor();
	}
	
	/**
	 * Renvoie le meilleur temps du joueur sur le circuit demandé, dans le mode indiqué,
	 * ainsi que la voiture utilisée pour le réaliser.
	 *
	 * Si le joueur n'a pas de temps enregistré sur le circuit, les champs vaudront 0
	 *
	 * Les paramètres doivent avoir été validés en amont.
	 */
	public function getPlayerLapRecord($id, $raceIndex, $modeIndex) 
	{
		$timerQuery = $this->link->prepare('SELECT NAME, CAR, TIME FROM users, laptimes WHERE users.ID = :id AND laptimes.ID = :id AND RACE = :race AND MODE = :mode');
		$timerQuery->bindValue(":id", $id, PDO::PARAM_STR);
		$timerQuery->bindValue(":race", $raceIndex, PDO::PARAM_INT);
		$timerQuery->bindValue(":mode", $modeIndex, PDO::PARAM_INT);
		$timerQuery->execute();
		
		$result = array('NAME' => '', 'CAR' => 0, 'TIME' => 0);
		$output = $timerQuery->fetch(PDO::FETCH_ASSOC);
		if (FALSE !== $output) {
			$result['NAME'] = htmlentities($output['NAME']);
			$result['TIME'] = intval($output['TIME']);
			$result['CAR'] = intval($output['CAR']);
		}
		$timerQuery->closeCursor();
		return $result;
	}
	
	/**
	 * Récupère le fantôme de la course d'un joueur donné, sur le circuit et dans le mode demandés
	 * Identique à getPlayerLapRecord() mais renvoie le ghost en plus
	 */
	public function getGhost($id, $raceIndex, $modeIndex) 
	{
		$query = $this->link->prepare('SELECT NAME, CAR, TIME, GHOST FROM users, laptimes WHERE users.ID = :id AND laptimes.ID = :id AND RACE = :race AND MODE = :mode');
		$query->bindValue(":id", $id, PDO::PARAM_STR);
		$query->bindValue(":race", $raceIndex, PDO::PARAM_INT);
		$query->bindValue(":mode", $modeIndex, PDO::PARAM_INT);
		$query->execute();
		
		$result = array('NAME' => '', 'CAR' => 0, 'TIME' => 0);
		$output = $query->fetch(PDO::FETCH_ASSOC);
		if (FALSE !== $output) {
			$result['NAME'] = htmlentities($output['NAME']);
			$result['TIME'] = intval($output['TIME']);
			$result['CAR'] = intval($output['CAR']);
			$result['GHOST'] = $output['GHOST'];
		}
		$query->closeCursor();
		return $result;
	}
	
	
	/**
	 * Récupère, pour une course et un mode donnés, les N meilleurs temps sous la forme d'un tableau
	 * "NAME" => nom du joueur, "CAR" => voiture utilisée, "TIME" => temps de course, "HOUR" => timestamp
	 */
	public function getBestLapTimes($raceIndex, $modeIndex, $count)
	{
		$timerQuery = $this->link->prepare("SELECT users.ID, NAME, CAR, TIME, HOUR FROM users, laptimes WHERE RACE = :race AND MODE = :mode AND users.ID = laptimes.ID ORDER BY TIME LIMIT :count");
		$timerQuery->bindValue(":race", $raceIndex, PDO::PARAM_INT);
		$timerQuery->bindValue(":mode", $modeIndex, PDO::PARAM_INT);
		$timerQuery->bindValue(":count", $count, PDO::PARAM_INT);
		$timerQuery->execute();
		
		$result = array();
		while ($output = $timerQuery->fetch(PDO::FETCH_ASSOC)) {
			$result[] = array('NAME' => htmlentities($output['NAME']), 'CAR' => intval($output['CAR']), 'TIME' => intval($output['TIME']), 'ID' => intval($output['ID']), 'HOUR' => $output['HOUR']);
		}
		
		$timerQuery->closeCursor();
		return $result;
	}
	
	
	/**
	 * Déverrouille un circuit pour un joueur donné.
	 * L'opération n'est effectuée que si la limite actuelle du joueur est le circuit précédent,
	 * auquel cas la méthode renvoie SUCCESS. Sinon rien ne se passe et la méthode renvoie
	 * INFO_ALREADY_UNLOCKED
	 * Les opérations précédentes (test par rapport au temps de qualification) ne connaissant pas 
	 * les circuits déja accessibles, cet appel sera effectué systématiquement, même si le joueur
	 * est déja plus loin dans le jeu (et qu'il rejoue un des circuits précédents). 
	 */
	public function unlockRace($userId, $raceIndex)
	{
		$query = $this->link->prepare("UPDATE career SET LEVEL = :race WHERE ID = :id AND LEVEL = (:race - 1) LIMIT 1");
		$query->bindValue(":id", $userId, PDO::PARAM_INT);
		$query->bindValue(":race", $raceIndex, PDO::PARAM_INT);
		$query->execute();
		$count = $query->rowCount();
		$query->closeCursor();
		return ($count == 1 ? SUCCESS : INFO_ALREADY_UNLOCKED);
	}
	
	/**
	 * Déverrouille une voiture pour un joueur donné, en basculant le bit correspondant dans 
	 * la table career. Une vérification sommaire est effectuée pour s'assurer que l'index rentre
	 * dans le champ CAR_MASK (32 bits).
	 * Renvoie SUCCESS si l'opération est réussie, un code d'erreur sinon
	 */
	public function unlockCar($userId, $carIndex)
	{
		if ($carIndex<1 || $carIndex>32) {
			ErrorHandler::getInstance()->setParameters($carIndex);
			return ERROR_INVALID_CAR;
		}
		$query = $this->link->prepare("UPDATE career SET CAR_MASK = CAR_MASK | (1 << (:car - 1)) WHERE ID = :id LIMIT 1");
		$query->bindValue(":id", $userId, PDO::PARAM_INT);
		$query->bindValue(":car", $carIndex, PDO::PARAM_INT);
		$query->execute();
		$count = $query->rowCount();
		$query->closeCursor();
		return ($count == 1 ? SUCCESS : INFO_ALREADY_UNLOCKED);
	}
	
	/**
	 * Déverrouille un mode de jeu pour un joueur donné, en basculant le bit correspondant dans 
	 * la table career. Une vérification sommaire est effectuée pour s'assurer que l'index rentre
	 * dans le champ MODE_MASK (8 bits).
	 * Renvoie SUCCESS si l'opération est réussie, un code d'erreur sinon
	 */
	public function unlockMode($userId, $modeIndex)
	{
		if ($modeIndex<1 || $modeIndex>8) {
			ErrorHandler::getInstance()->setParameters($modeIndex);
			return ERROR_INVALID_MODE;
		}
		$query = $this->link->prepare("UPDATE career SET MODE_MASK = MODE_MASK | (1 << (:mode-1)) WHERE ID = :id LIMIT 1");
		$query->bindValue(":id", $userId, PDO::PARAM_INT);
		$query->bindValue(":mode", $modeIndex, PDO::PARAM_INT);
		$query->execute();
		$count = $query->rowCount();
		$query->closeCursor();
		return ($count == 1 ? SUCCESS : INFO_ALREADY_UNLOCKED);
	}
	
	/**
	 * Récupère, pour affichage dans la console admin, les infos sur un joueur
	 * à partir de son nom ou de son Id
	 */
	public function adminGetUserCredentials($userId, $userName)
	{
		$query=0;
		if ($userId==0) {
			$query = $this->link->prepare("SELECT users.ID, NAME, STATUS, LEVEL, CAR_MASK, MODE_MASK FROM career,users WHERE NAME = :name AND career.ID = users.ID");
			$query->bindValue(":name", $userName, PDO::PARAM_STR);
		} else {
			$query = $this->link->prepare("SELECT users.ID, NAME, STATUS, LEVEL, CAR_MASK, MODE_MASK FROM career,users WHERE career.ID = :id AND users.ID = :id");
			$query->bindValue(":id", $userId, PDO::PARAM_INT);
		}
		$result = array ('ID' => 0, 'NAME' => '');
		$query->execute();
		$output = $query->fetch(PDO::FETCH_ASSOC);
		if (FALSE != $output) {
			$result['ID'] = intval($output['ID']);
			$result['NAME'] = htmlentities($output['NAME']);
			$result['STATUS'] = intval($output['STATUS']);
			$result['LEVEL'] = intval($output['LEVEL']);
			$result['CAR_MASK'] = intval($output['CAR_MASK']);
			$result['MODE_MASK'] = intval($output['MODE_MASK']);
		}
		$query->closeCursor();
		return $result;
	}
	
	/**
	 * Renvoie la liste des ID et noms des joueurs dont le statut correspond à la
	 * valeur fournie en paramètre. 
	 * Fonction prévue pour la gestion des status en attente de validation manuelle ou de confirmation
	 * Renvoie un tableau avec la liste correspondante
	 */
	public function adminGetUserByStatus($status)
	{
		$query = $this->link->prepare("SELECT ID, NAME, STATUS, LAST_LOGIN FROM users WHERE STATUS = :status ORDER BY ID");
		$query->bindValue(":status", $status, PDO::PARAM_INT);
		
		$query->execute();
		
		$result = array();
		while ($output = $query->fetch(PDO::FETCH_ASSOC))
		{
			$result[] = $output;
		}
		
		$query->closeCursor();
		return $result;
	}
	
	/**
	 * Récupère, pour un joueur donné, l'ensemble de ses records
	 * sur tous les circuits, dans tous les modes
	 */
	public function adminGetUserLapTimes($userId)
	{
		$query = $this->link->prepare("SELECT NAME, RACE, MODE, CAR, TIME, HOUR FROM users, laptimes WHERE users.ID = :id AND laptimes.ID = :id");
		$query->bindValue(":id", $userId, PDO::PARAM_INT);
		$query->execute();
		
		$result = array();
		while ($output = $query->fetch(PDO::FETCH_ASSOC))
		{
			$output['ID'] = $userId;
			$result[] = $output;
		}
		
		$query->closeCursor();
		return $result;
	}
	
	/**
	 * Récupère la liste des incidents d'un niveau de sévérité donnée
	 * pour affichage dans la console d'administration
	 */
	public function adminGetIncidents($severity, $offset, $count)
	{
		$query = $this->link->prepare("SELECT ID, USERID, NAME, HOUR, ERRORCODE, MESSAGE, RACE, MODE, CAR, TIME FROM incidents WHERE ERRORCODE>=:codeMin AND ERRORCODE<:codeMax LIMIT :offset, :count");
		$query->bindValue(":codeMin", 1000*$severity, PDO::PARAM_INT);
		$query->bindValue(":codeMax", 1000*($severity+1), PDO::PARAM_INT);
		$query->bindValue(":offset", $offset, PDO::PARAM_INT);
		$query->bindValue(":count", $count, PDO::PARAM_INT);
		$query->execute();
		$result = array();
		while ($output = $query->fetch(PDO::FETCH_ASSOC))
		{
			$result[] = $output;
		}
		
		$query->closeCursor();
		return $result;
	}
	
	/**
	 * Récupère le fantôme de la course liée à un incident
	 * Identique à getPlayerLapRecord() mais renvoie le ghost en plus
	 */
	public function adminGetIncidentGhost($id) 
	{
		$query = $this->link->prepare('SELECT USERID, users.NAME, CAR, RACE, MODE, TIME, GHOST FROM users, incidents WHERE users.ID = incidents.USERID AND incidents.ID = :id');
		$query->bindValue(":id", $id, PDO::PARAM_STR);
		$query->execute();
		
		$result = array('NAME' => '', 'CAR' => 0, 'TIME' => 0);
		$output = $query->fetch(PDO::FETCH_ASSOC);
		if (FALSE !== $output) {
			$result['NAME'] = htmlentities($output['NAME']);
			$result['RACE'] = intval($output['RACE']);
			$result['MODE'] = intval($output['MODE']);
			$result['TIME'] = intval($output['TIME']);
			$result['CAR'] = intval($output['CAR']);
			$result['GHOST'] = $output['GHOST'];
		}
		$query->closeCursor();
		return $result;
	}
	
	/**
	 * Fonction administrateur :
	 * Supprime un enregistrement de la base laptimes (télémétrie et record)
	 */
	public function adminDeleteRaceRecord($playerId, $modeId, $trackId)
	{
		$query = $this->link->prepare('DELETE FROM laptimes WHERE ID = :id AND MODE = :mode AND RACE = :race LIMIT 1');
		$query->bindValue(":id", $playerId, PDO::PARAM_INT);
		$query->bindValue(":mode", $modeId, PDO::PARAM_INT);
		$query->bindValue(":race", $trackId, PDO::PARAM_INT);
		$query->execute();
		$result = SUCCESS;
		$errorDetails = $query->errorInfo();
		$query->closeCursor();
		if (strcmp('00000', $errorDetails[0]) != 0) {
			ErrorHandler::getInstance()->setParameters($errorDetails[0].' : '.$errorDetails[2]);
			return ERROR_GENERIC_DB_ERROR;
		}
		return SUCCESS;
	}
	
	/**
	 * Fonction administrateur :
	 * Supprime un ensemble d'enregistrements de la base des incidents
	 *  - $incidentIds : tableau contenant les ID concernés
	 * Renvoie SUCCESS en cas de réussite, ou un code d'erreur associé
	 */
	public function adminDeleteIncidents($incidentIds)
	{
		$queryText = 'DELETE FROM incidents WHERE ID = :id0';
		$idCount = count($incidentIds);
		if ($idCount < 1) {
			ErrorHandler::getInstance()->setParameters("Aucun élément à supprimer.");
			return WARN_NO_OPERATION;
		}
		for ($index=1; $index<$idCount; ++$index) {
			$queryText.= ' OR ID = :id'.$index;
		}
		$queryText.=' LIMIT '.$idCount;
		
		$query = $this->link->prepare($queryText);
		foreach($incidentIds as $index => $id)
		{
			$query->bindValue(":id".$index, $id, PDO::PARAM_INT);
		}
		$query->execute();
		$result = SUCCESS;
		$errorDetails = $query->errorInfo();
		$query->closeCursor();
		if (strcmp('00000', $errorDetails[0]) != 0) {
			ErrorHandler::getInstance()->setParameters($errorDetails[0].' : '.$errorDetails[2]);
			return ERROR_GENERIC_DB_ERROR;
		}
		return SUCCESS;
	}
	
	/**
	 * Fonction administrateur :
	 * Récupère la télémétrie enregistrée dans l'incident indiqué (base incidents),
	 * et l'enregistre dans la table des records (laptimes).
	 * Si le joueur a déja un meilleur temps, la recopie n'est pas effectuée.
	 * Dans tous les cas, l'incident est ensuite effacé
	 */
	public function adminValidateRaceFromIncident($incidentId)
	{
		$checkQuery = $this->link->prepare('SELECT laptimes.TIME as OLDTIME, incidents.TIME as NEWTIME FROM laptimes, incidents WHERE incidents.ID = :incidentId AND laptimes.ID = incidents.USERID AND laptimes.RACE = incidents.RACE AND laptimes.MODE = incidents.MODE');
		$checkQuery->bindValue(":incidentId", $incidentId, PDO::PARAM_INT);
		$checkQuery->execute();
		$output = $checkQuery->fetch(PDO::FETCH_ASSOC);
		$checkQuery->closeCursor();
		
		$queryText = '';
		if (FALSE === $output) {
			// pas de record précédent du joueur sur ce circuit : on le crée directement
			$queryText = 'INSERT INTO laptimes (ID, RACE, MODE, CAR, TIME, GHOST, HOUR) SELECT USERID, RACE, MODE, CAR, TIME, GHOST, HOUR FROM incidents WHERE ID = :incidentId';
		} else if (intval($output['NEWTIME']) < intval($output['OLDTIME'])) {
			// le record de la télémétrie en incident est meilleur que le précédent du joueur : on remplace
			$queryText = 'REPLACE INTO laptimes (ID, RACE, MODE, CAR, TIME, GHOST, HOUR) SELECT USERID, RACE, MODE, CAR, TIME, GHOST, HOUR FROM incidents WHERE ID = :incidentId';
		} // pas de else : si la télémétrie en incident est moins bonne, on ne recopie rien
		if (strcmp('', $queryText) != 0) {
			$query = $this->link->prepare($queryText);
			$query->bindValue(":incidentId", $incidentId, PDO::PARAM_INT);
			$query->execute();
			$errorDetails = $query->errorInfo();
			$query->closeCursor();
			if (strcmp('00000', $errorDetails[0]) != 0) {
				ErrorHandler::getInstance()->setParameters($errorDetails[0].' : '.$errorDetails[2]);
				return ERROR_GENERIC_DB_ERROR;
			}
		}
		
		// ensuite on supprime l'incident
		return $this->adminDeleteIncidents(array($incidentId));
	}
		
	/**
	 * Ferme la connexion avec la base de données
	 */	
	public function close()
	{
		$this->link = null;
	}
	
	/**
	 * Affiche un message d'erreur (suivi d'un CR),
	 * uniquement si le flag $this->debug est activé
	 */
	private function debugWrite($msg)
	{
		if ($this->debug) {
			echo "Debug : ".$msg."<br>";
		}
	}
}

?>