<?php
declare(strict_types = 1);

/**
 * Gestion des commentaires.
 *
 * @license https://www.gnu.org/licenses/gpl-3.0.html
 * @link https://www.igalerie.org/
 */
class Comment
{
	/**
	 * Longueur maximale des colonnes.
	 *
	 * @var array
	 */
	CONST COLUMNS_LENGTH_MAX =
	[
		'author' => 24,
		'email' => 128,
		'message' => 9999,
		'website' => 128
	];



	private static $_author;
	private static $_email;
	private static $_message;
	private static $_simulate;
	private static $_userId;
	private static $_website;



	/**
	 * Enregistre un commentaire en base de données.
	 *
	 * @param int $item_id
	 *   Identifiant du fichier.
	 * @param int $user_id
	 *   Identifiant de l'utilisateur.
	 * @param bool $moderate
	 *   Le commentaire doit-il être modéré ?
	 * @param bool $simulate
	 *   Doit-on simuler l'enregistrement ?
	 * @param string $message
	 *   Message.
	 * @param string $author
	 *   Nom de l'utilisateur.
	 * @param string $email
	 *   Adresse de courriel de l'utilisateur.
	 * @param string $website
	 *   Adresse du site Web de l'utilisateur.
	 *
	 * @return mixed
	 *   Retourne TRUE en cas de succès, FALSE si une erreur est survenue,
	 *   ou un tableau en cas de rejet du commentaire.
	 */
	public static function add(int $item_id, int $user_id, bool $moderate, bool $simulate,
	string $message, string $author, string $email = '', string $website = '')
	{
		self::$_author   =& $author;
		self::$_email    =& $email;
		self::$_message  =& $message;
		self::$_simulate =& $simulate;
		self::$_userId   =& $user_id;
		self::$_website  =& $website;

		// Antiflood.
		if (($r = self::_antiflood()) !== NULL)
		{
			return $r;
		}

		// Vérification du message.
		if (($r = self::checkMessage($message)) !== NULL)
		{
			return $r;
		}

		// Vérification des autres champs uniquement pour les utilisateurs non enregistrés.
		if ($user_id == 2)
		{
			if (($r = self::checkAuthor($author)) !== NULL
			 || ($r = self::checkEmail($email)) !== NULL
			 || ($r = self::checkWebsite($website)) !== NULL)
			{
				return $r;
			}
		}

		// Listes noires.
		if ($user_id == 2)
		{
			$post = ['author' => $author, 'email' => $email,
				'website' => $website, 'message' => $message];
		}
		else
		{
			$post = ['message' => $message];
		}
		$r = App::blacklists(
			$simulate ? '' : 'comment_add',
			$user_id == 2 ? $author : '',
			$user_id == 2 ? $email : '',
			$message,
			$post
		);
		if (is_array($r))
		{
			switch ($r['list'])
			{
				case 'emails' : $field = 'email'; break;
				case 'names' : $field = 'author'; break;
				case 'words' : $field = 'message'; break;
				default : $field = '';
			}
			return
			[
				'field' => $field,
				'error' => 'blacklist',
				'message' => $r['text']
			];
		}

		// En mode simulation, on ne va pas plus loin.
		if ($simulate)
		{
			return TRUE;
		}

		// État du commentaire.
		$status = $moderate ? -1 : 1;

		// Début de la transaction.
		if (!DB::beginTransaction())
		{
			return FALSE;
		}

		// Enregistrement du commentaire.
		$sql = 'INSERT INTO {comments} (
				item_id, user_id, com_crtdt, com_lastupddt, com_author,
				com_email, com_website, com_ip, com_message, com_status
			  ) VALUES (
				:item_id, :user_id, NOW(), NOW(), :com_author,
				:com_email, :com_website, :com_ip, :com_message, :com_status
			  )';
		$params =
		[
			'item_id' => $item_id,
			'user_id' => $user_id,
			'com_author' => Utility::trimAll($author),
			'com_email' => $user_id == 2 ? Utility::trimAll($email) : NULL,
			'com_website' => $user_id == 2 ? Utility::trimAll($website) : NULL,
			'com_ip' => $_SERVER['REMOTE_ADDR'],
			'com_message' => Utility::trimAll($message),
			'com_status' => $status
		];
		if (!DB::execute($sql, $params))
		{
			return DB::rollback(FALSE);
		}

		// Mise à jour du nombre de commentaires pour
		// le fichier et ses catégories parentes, mais seulement
		// si le commentaire est activé tout de suite.
		if (!$moderate)
		{
			$sql = 'SELECT cat_id,
						   cat_parents
					  FROM {categories}
				 LEFT JOIN {items} USING (cat_id)
					 WHERE item_id = ?';
			if (!DB::execute($sql, $item_id))
			{
				return DB::rollback(FALSE);
			}
			$cat_infos = DB::fetchRow();
			$cat_ids = DB::inInt(Category::getParentsIdArray(
				$cat_infos['cat_parents'], $cat_infos['cat_id']
			));
			$sql =
			[
				"UPDATE {items}
					SET item_comments = item_comments + 1
				  WHERE item_id = $item_id",

				"UPDATE {categories}
					SET cat_a_comments = cat_a_comments + 1
				  WHERE cat_id IN ($cat_ids)"
			];
			foreach ($sql as &$q)
			{
				if (!DB::execute($q))
				{
					return DB::rollback(FALSE);
				}
			}
		}

		// Log d'activité.
		self::_logActivity('comment_add');

		// Exécution de la transaction.
		if (!DB::commitTransaction())
		{
			return DB::rollback(FALSE);
		}

		return TRUE;
	}

	/**
	 * Ajoute des auteurs ou des IP de commentaires
	 * à la liste noire correspondante.
	 *
	 * @param string $list
	 *   Nom de la liste noire : 'names' ou 'ips'.
	 * @param array $comments_id
	 *   Identifiant des commentaires.
	 *
	 * @return int
	 *   Retourne le nombre d'auteurs ajoutés en liste noire
	 *   ou -1 en cas d'erreur.
	 */
	public static function ban(string $list, array $comments_id): int
	{
		if (!in_array($list, ['names', 'ips']) || !$comments_id)
		{
			return 0;
		}

		$blacklist = $update = preg_split('`[\r\n]+`',
			Config::$params['blacklist_' . $list], -1, PREG_SPLIT_NO_EMPTY);
		$column = $list == 'ips' ? 'com_ip' : 'com_author';

		$sql = "SELECT $column
				  FROM {comments}
				 WHERE com_id IN (" . DB::inInt($comments_id) . ")
				   AND user_id = 2";
		if (!DB::execute($sql))
		{
			return -1;
		}

		foreach (DB::fetchAll($column, $column) as &$item)
		{
			if (!in_array($item, $update))
			{
				$update[] = $item;
			}
		}

		if ($blacklist === $update)
		{
			return 0;
		}

		$sql = "UPDATE {config} SET conf_value = ? WHERE conf_name = 'blacklist_$list'";
		if (!DB::execute($sql, implode("\n", $update)))
		{
			return -1;
		}

		return count($update) - count($blacklist);
	}

	/**
	 * Vérification du champ "auteur".
	 *
	 * @param string $website
	 *
	 * @return mixed
	 */
	public static function checkAuthor(string $author)
	{
		// On vérifie que le champ n'est pas vide.
		if (Utility::isEmpty($author))
		{
			return
			[
				'field' => 'author',
				'error' => 'empty',
				'message' => __('Votre nom n\'a pas été renseigné.')
			];
		}

		// On vérifie la longueur.
		if (mb_strlen($author) > self::COLUMNS_LENGTH_MAX['author'])
		{
			return
			[
				'field' => 'author',
				'error' => 'length',
				'message' => sprintf(
					__('La longueur ne doit pas dépassée %s caractères.'),
					self::COLUMNS_LENGTH_MAX['author']
				)
			];
		}
	}

	/**
	 * Vérification du champ "courriel".
	 *
	 * @param string $website
	 *
	 * @return mixed
	 */
	public static function checkEmail(string $email)
	{
		// On vérifie que le champ n'est pas vide.
		if (Config::$params['comments_required_email'] && Utility::isEmpty($email))
		{
			return
			[
				'field' => 'email',
				'error' => 'empty',
				'message' => __('Votre adresse de courriel n\'a pas été renseignée.')
			];
		}

		// On vérifie la longueur.
		if (mb_strlen($email) > self::COLUMNS_LENGTH_MAX['email'])
		{
			return
			[
				'field' => 'email',
				'error' => 'length',
				'message' => sprintf(
					__('La longueur ne doit pas dépassée %s caractères.'),
					self::COLUMNS_LENGTH_MAX['email']
				)
			];
		}

		// On vérifie le format.
		if (!preg_match('`^(?:' . Utility::emailRegexp() . ')?$`i', $email))
		{
			return
			[
				'field' => 'email',
				'error' => 'format',
				'message' => __('Format de l\'adresse de courriel incorrect.')
			];
		}
	}

	/**
	 * Vérification du message.
	 *
	 * @param string $message
	 * @param string $log
	 *
	 * @return mixed
	 */
	public static function checkMessage(string $message, bool $log = TRUE)
	{
		// On vérifie que le champ n'est pas vide.
		if (Utility::isEmpty($message))
		{
			return
			[
				'field' => 'message',
				'error' => 'empty',
				'message' => __('Le message que vous avez envoyé est vide.')
			];
		}

		// On vérifie le nombre de caractères.
		$maxchars = (int) Config::$params['comments_maxchars'];
		if ($maxchars > self::COLUMNS_LENGTH_MAX['message'])
		{
			$maxchars = self::COLUMNS_LENGTH_MAX['message'];
		}
		if (mb_strlen($message) > $maxchars)
		{
			if ($log)
			{
				self::_logActivity('comment_add_rejected_maxchars', $maxchars);
			}

			return
			[
				'field' => 'message',
				'error' => 'length',
				'message' => sprintf(L10N::getText('message_maxlength'), $maxchars)
			];
		}

		// On vérifie le nombre de lignes.
		$maxlines = (int) Config::$params['comments_maxlines'];
		if ($maxlines > 999)
		{
			$maxlines = 999;
		}
		$test = preg_split('`(?:\r\n|[\r\n])`', $message);
		if (count($test) > $maxlines)
		{
			if ($log)
			{
				self::_logActivity('comment_add_rejected_maxlines', $maxlines);
			}

			return
			[
				'field' => 'message',
				'error' => 'lines',
				'message' => sprintf(
					__('Votre message ne doit pas comporter plus de %s lignes.'),
					$maxlines
				)
			];
		}

		// On vérifie le nombre d'URL.
		$maxurls = (int) Config::$params['comments_maxurls'];
		if ($maxurls > 99)
		{
			$maxurls = 99;
		}
		$regexp = '`(?:.*?' . Utility::URLRegexp() . '.*?){' . ($maxurls + 1) . '}`is';
		if (preg_match($regexp, $message))
		{
			if ($log)
			{
				self::_logActivity('comment_add_rejected_maxurls', $maxurls);
			}

			return
			[
				'field' => 'message',
				'error' => 'urls',
				'message' => $maxurls > 0
					? sprintf(__('Votre message ne doit pas contenir plus de %s URL.'), $maxurls)
					: __('Votre message ne doit contenir aucun URL.')
			];
		}
	}

	/**
	 * Vérification du champ "site Web".
	 *
	 * @param string $website
	 *
	 * @return mixed
	 */
	public static function checkWebsite(string $website)
	{
		// On vérifie que le champ n'est pas vide.
		if (Config::$params['comments_required_website'] && Utility::isEmpty($website))
		{
			return
			[
				'field' => 'website',
				'error' => 'empty',
				'message' => __('L\'adresse de votre site Web n\'a pas été renseignée.')
			];
		}

		// On vérifie la longueur.
		if (mb_strlen($website) > self::COLUMNS_LENGTH_MAX['website'])
		{
			return
			[
				'field' => 'website',
				'error' => 'length',
				'message' => sprintf(
					__('La longueur ne doit pas dépassée %s caractères.'),
					self::COLUMNS_LENGTH_MAX['website']
				)
			];
		}

		// On vérifie le format.
		$website = preg_replace('`^\s*' . Utility::URLRegexp('protocol') . '`i', '', $website);
		if (!preg_match('`^(?:' . Utility::URLRegexp('url', FALSE) . ')?$`i', $website))
		{
			return
			[
				'field' => 'website',
				'error' => 'format',
				'message' => __('Format de l\'adresse du site Web incorrect.')
			];
		}
	}

	/**
	 * Supprime plusieurs commentaires.
	 *
	 * @param array $comments_id
	 *   Identifiant des commentaires.
	 *
	 * @return int
	 *   Retourne le nombre de commentaires affectés
	 *   ou -1 en cas d'erreur.
	 */
	public static function delete(array $comments_id): int
	{
		if (!$comments_id)
		{
			return 0;
		}

		return self::_action($comments_id, 'delete');
	}

	/**
	 * Édite plusieurs commentaires.
	 *
	 * @param array $update
	 *   Informations de mise à jour.
	 *
	 * @return int
	 *   Retourne le nombre de commentaires affectés
	 *   ou -1 en cas d'erreur.
	 */
	public static function edit(array &$update): int
	{
		// Récupération des commentaires à éditer.
		$sql = 'SELECT com_id,
					   com_author,
					   com_email,
					   com_message,
					   com_website
				  FROM {comments}
				 WHERE com_id IN (' . DB::inInt(array_keys($update)) . ')';
		if (!DB::execute($sql))
		{
			return -1;
		}
		$comments = DB::fetchAll('com_id');

		// On détermine quels commentaires doivent être mis à jour.
		$params = [];
		foreach ($update as $id => &$data)
		{
			$change = FALSE;
			foreach (self::COLUMNS_LENGTH_MAX as $col => $limit)
			{
				$$col = $data[$col] ?? $comments[$id]['com_' . $col];
				$$col = $$col === NULL
					? NULL
					: mb_strimwidth(Utility::trimAll((string) $$col), 0, $limit);
				if ($$col !== $comments[$id]['com_' . $col])
				{
					$change = TRUE;
				}
			}
			if ($change)
			{
				$params[] =
				[
					'author' => $author,
					'email' => $email,
					'id' => $id,
					'message' => $message,
					'website' => $website
				];
			}
		}
		if (!$params)
		{
			return 0;
		}

		// Mise à jour de la base de données.
		$sql = 'UPDATE {comments}
				   SET com_message = :message,
				       com_author = :author,
					   com_email = :email,
					   com_website = :website,
					   com_lastupddt = NOW()
				 WHERE com_id = :id';
		if (!DB::execute($sql, $params))
		{
			return -1;
		}

		return DB::rowCount();
	}

	/**
	 * Change l'état de plusieurs commentaires.
	 *
	 * @param array $comments_id
	 *   Identifiant des commentaires.
	 * @param int $status
	 *   État (0 ou 1).
	 *
	 * @return int
	 *   Retourne le nombre de commentaires affectés
	 *   ou -1 en cas d'erreur.
	 */
	public static function status(array $comments_id, int $status): int
	{
		if (!$comments_id || $status < 0 || $status > 1)
		{
			return 0;
		}

		return self::_action($comments_id, $status ? 'activate' : 'deactivate');
	}



	/**
	 * Action sur des commentaires.
	 *
	 * @param array $comments_id
	 * @param string $action
	 *   Action à effectuer : 'activate', 'deactivate' ou 'delete'.
	 *
	 * @return int
	 */
	private static function _action(array $comments_id, string $action): int
	{
		// Début de la transaction.
		if (!DB::beginTransaction())
		{
			return -1;
		}

		$new_status = $action == 'activate' ? 1 : 0;

		// Récupération des informations utiles des fichiers sur
		// lesquels ont été postés les commentaires.
		$status = $action == 'delete'
			? ''
			: " AND com_status != '$new_status'";
		$sql = "SELECT com_id,
					   com_status,
					   i.item_id,
					   item_status,
					   cat.cat_id,
					   cat_parents
				  FROM {comments} AS com,
					   {items} AS i,
					   {categories} AS cat
				 WHERE com.item_id = i.item_id
				   AND i.cat_id = cat.cat_id
				   AND com_id IN (" . DB::inInt($comments_id) . ")
					   $status";
		if (!DB::execute($sql))
		{
			return DB::rollback(-1);
		}
		$infos = DB::fetchAll();
		if (!$infos)
		{
			return 0;
		}

		// Réorganisation des informations.
		$comments_id = [];
		$items_comments = [];
		$items_infos = [];
		foreach ($infos as &$i)
		{
			$items_comments[(int) $i['item_id']][$i['com_id']] = $i['com_status'];
			$items_infos[$i['item_id']] =
			[
				'item_status' => $i['item_status'],
				'cat_id' => $i['cat_id'],
				'cat_parents' => $i['cat_parents']
			];
			$comments_id[] = $i['com_id'];
		}

		// Suppression.
		if ($action == 'delete')
		{
			$sql = 'DELETE FROM {comments} WHERE com_id IN (' . DB::inInt($comments_id) . ')';
		}

		// Changement d'état.
		else
		{
			$sql = "UPDATE {comments}
					   SET com_status = '$new_status'
					 WHERE com_id IN (" . DB::inInt($comments_id) . ")
					   AND com_status != '$new_status'";
		}
		if (!DB::execute($sql))
		{
			return DB::rollback(-1);
		}
		$row_count = DB::rowCount();

		// Mise à jour du nombre de commentaires sur les fichiers
		// et leurs catégories parentes.
		$op = $action == 'activate' ? '+' : '-';
		foreach ($items_comments as $item_id => &$comments_id)
		{
			$i =& $items_infos[$item_id];
			$parents_id = Category::getParentsIdArray($i['cat_parents'], $i['cat_id']);

			// Quand supression ou désactivation de commentaires,
			// il faut updater uniquement le nombre de commentaires activés.
			if ($action == 'delete' || $action == 'deactivate')
			{
				$comments_id = array_filter($comments_id, function($a)
				{
					return $a == 1;
				});
			}

			// Nombre de commentaires à updater.
			if (($comments_count = count($comments_id)) == 0)
			{
				continue;
			}

			// Mise à jour de la base de données.
			$col = $i['item_status'] == '1' ? 'a' : 'd';
			$sql = "UPDATE {items}
					   SET item_comments = item_comments $op $comments_count
					 WHERE item_id = $item_id";
			if (!DB::execute($sql))
			{
				return DB::rollback(-1);
			}

			$sql = "UPDATE {categories}
					   SET cat_{$col}_comments
						 = cat_{$col}_comments $op $comments_count
					 WHERE cat_id IN (" . DB::inInt($parents_id) . ")";
			if (!DB::execute($sql))
			{
				return DB::rollback(-1);
			}
		}

		if (CONF_DEV_MODE && Maintenance::dbStats() !== 0)
		{
			trigger_error('Gallery stats error.', E_USER_WARNING);
			return DB::rollback(-1);
		}

		// Exécution de la transaction.
		if (!DB::commitTransaction())
		{
			return DB::rollback(-1);
		}

		return $row_count;
	}

	/**
	 * Vérification antiflood.
	 *
	 * @return mixed
	 */
	private static function _antiflood()
	{
		$antiflood = (int) Config::$params['comments_antiflood'];
		$sql_where = self::$_userId == 2 ? "com_ip = ?" : "user_id = ?";
		$param = self::$_userId == 2 ? $_SERVER['REMOTE_ADDR'] : self::$_userId;
		$sql = "SELECT TO_SECONDS(NOW()) - TO_SECONDS(com_crtdt)
				  FROM {comments}
				 WHERE $sql_where
			  ORDER BY com_crtdt DESC
				 LIMIT 1";
		if (!DB::execute($sql, $param))
		{
			return FALSE;
		}
		$seconds = DB::fetchVal();
		if ($seconds !== NULL && $seconds > -1 && $seconds < $antiflood)
		{
			self::_logActivity('comment_add_rejected_antiflood', $antiflood);

			return
			[
				'field' => '',
				'error' => 'antiflood',
				'message' => sprintf('Vous devez patienter encore %s secondes avant de '
					. 'pouvoir poster un nouveau commentaire.', $antiflood - $seconds)
			];
		}
	}

	/**
	 * Enregistre l'activité de l'utilisateur.
	 *
	 * @param string $action
	 * @param mixed $match
	 *
	 * @return void
	 */
	private static function _logActivity(string $action, $match = ''): void
	{
		if (!self::$_simulate)
		{
			App::logActivity($action, (string) $match,
			[
				'author'  => self::$_author,
				'email'   => self::$_email,
				'website' => self::$_website,
				'message' => self::$_message
			]);
		}
	}
}
?>