<?php
declare(strict_types = 1);

/**
 * Gestion des images et vidéos.
 *
 * @license https://www.gnu.org/licenses/gpl-3.0.html
 * @link https://www.igalerie.org/
 */
class Item
{
	/**
	 * Types de fichier correspondant à une image.
	 *
	 * @var array
	 */
	const IMAGE_TYPES =
	[
		self::TYPE_AVIF,
		self::TYPE_GIF,
		self::TYPE_GIF_ANIMATED,
		self::TYPE_JPEG,
		self::TYPE_PNG,
		self::TYPE_PNG_ANIMATED,
		self::TYPE_WEBP,
		self::TYPE_WEBP_ANIMATED
	];

	/**
	 * Extensions de fichiers autorisés pour chaque type MIME.
	 *
	 * @var array
	 */
	const MIME_EXT =
	[
		'image/avif' => ['avif'],
		'image/gif'  => ['gif'],
		'image/jpeg' => ['jfif', 'jpeg', 'jpg'],
		'image/png'  => ['png'],
		'image/webp' => ['webp'],
		'video/mp4'  => ['mp4'],
		'video/webm' => ['webm']
	];

	/**
	 * Types de fichier tels qu'utilisés par l'application (et non PHP).
	 * Les images ont un code commençant par 1 alors
	 * que les vidéos ont un code commençant par 2.
	 *
	 * @var int
	 */
	const TYPE_JPEG = 100;
	const TYPE_GIF = 101;
	const TYPE_GIF_ANIMATED = 102;
	const TYPE_PNG = 103;
	const TYPE_PNG_ANIMATED = 104;
	const TYPE_WEBP = 105;
	const TYPE_WEBP_ANIMATED = 106;
	const TYPE_AVIF = 107;
	const TYPE_MP4 = 200;
	const TYPE_WEBM = 201;

	/**
	 * Types de fichier correspondant à une vidéo.
	 *
	 * @var array
	 */
	const VIDEO_TYPES =
	[
		self::TYPE_MP4,
		self::TYPE_WEBM
	];



	/**
	 * Retourne le nombre de fichiers par type.
	 *
	 * @param array $data
	 *   Informations provenant de la base de données en
	 *   effectuant une requête formée de cette manière :
	 *   SELECT COUNT(*) AS count, item_type ... GROUP BY item_type
	 *
	 * @return array
	 */
	public static function countByTypes(array $data): array
	{
		$images = 0;
		$items = 0;
		$videos = 0;
		$types = [];
		foreach (self::IMAGE_TYPES as &$type)
		{
			$types[$type] = $count = $data[$type]['count'] ?? 0;
			$items += $count;
			$images += $count;
		}
		foreach (self::VIDEO_TYPES as &$type)
		{
			$types[$type] = $count = $data[$type]['count'] ?? 0;
			$items += $count;
			$videos += $count;
		}
		return
		[
			'images' => $images,
			'items' => $items,
			'videos' => $videos,
			'types' => $types
		];
	}

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

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

		// Récupération des informations des fichiers.
		if (!$items_by_album = self::_getItemsByParent($items_id))
		{
			return DB::rollback(0);
		}

		// On traite les fichiers album par album.
		$items_deleted = 0;
		$update_infos = [];
		$update_parents_id = [];
		foreach ($items_by_album as $cat_id => &$items_infos)
		{
			// Suppression des fichiers et de tous les tags,
			// votes et commentaires liés.
			$sql = 'DELETE
					  FROM {items}
					 WHERE item_id IN (' . DB::inInt(array_keys($items_infos)) . ')';
			if (!DB::execute($sql))
			{
				return DB::rollback(-1);
			}
			$items_deleted += DB::rowCount();

			// On calcule les valeurs des colonnes
			// des catégories parentes à mettre à jour.
			$stats =
			[
				'albums' => 0,
				'comments' => 0,
				'favorites' => 0,
				'hits' => 0,
				'images' => 0,
				'rating' => 0,
				'size' => 0,
				'videos' => 0,
				'votes' => 0
			];
			$cat_update = ['a' => $stats, 'd' => $stats];
			foreach ($items_infos as &$i)
			{
				$status = $i['item_status'] == '1' ? 'a' : 'd';

				// On recalcule les stats.
				self::_parentsUpdateStats($cat_update[$status], $i);
			}

			// Mise à jour des statistiques des catégories parentes.
			$parents_id = Category::getParentsIdArray($i['cat_parents']);
			$update_parents_id += array_flip($parents_id);
			$parents_id[] = $cat_id;
			$update_infos += array_flip($parents_id);
			if (!Parents::updateStats($cat_update, '-', '-', $parents_id))
			{
				return DB::rollback(-1);
			}
		}

		if (!$update_parents_id)
		{
			return DB::rollback(0);
		}

		// On met à jour la date de publication du dernier fichier
		// et la vignette des catégories parentes.
		if (!Parents::updateInfos(array_keys($update_infos)))
		{
			return DB::rollback(-1);
		}

		// On met à jour le nombre de sous-catégories.
		if (!Parents::updateSubCats(array_keys($update_parents_id)))
		{
			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);
		}

		// On supprime les fichiers sur le disque.
		foreach ($items_by_album as &$items_infos)
		{
			foreach ($items_infos as &$i)
			{
				$file = CONF_ALBUMS_PATH . '/' . $i['item_path'];
				if (!file_exists($file))
				{
					continue;
				}
				if (!File::unlink($file))
				{
					return -1;
				}
			}
		}

		// On supprime les images redimensionnées
		// et les captures d'images des vidéos.
		self::deleteResized($items_id, ['captures', 'resize', 'thumbs']);

		return $items_deleted;
	}

	/**
	 * Supprime les images redimensionnées, vignettes
	 * et captures vidéos de plusieurs fichiers.
	 *
	 * @param array $items_id
	 *   Identifiant des fichiers.
	 * @param array $directories
	 *   Nom des sous-répertoires du répertoire 'cache'
	 *   dans lesquels supprimer les fichiers.
	 *
	 * @return int
	 *   Retourne le nombre de fichiers supprimés
	 *   ou -1 en cas d'erreur.
	 */
	public static function deleteResized(array $items_id,
	array $directories = ['captures', 'resize', 'thumbs']): int
	{
		if (!$items_id)
		{
			return 0;
		}

		if (in_array('captures', $directories))
		{
			$sql = 'UPDATE {items}
					   SET item_duration = NULL
					 WHERE item_id IN (' . DB::inInt($items_id) . ')';
			if (!DB::execute($sql))
			{
				return -1;
			}

			Config::changeDBParams(['video_captures' => 1]);
		}

		$files_deleted = 0;
		foreach ($directories as $dir)
		{
			$dir = GALLERY_ROOT . '/cache/' . $dir . '/';
			if (!$files = scandir($dir))
			{
				return -1;
			}

			foreach ($files as &$f)
			{
				$p = explode('-', $f);
				if (in_array($p[0], $items_id))
				{
					$files_deleted += (int) File::unlink($dir . $f);
				}
			}
		}

		return $files_deleted;
	}

	/**
	 * Envoi une archive Zip contenant les fichiers $items_id.
	 *
	 * @param string $archive_name
	 *   Nom de l'archive.
	 * @param array $items_id
	 *   Identifiant des fichiers à télécharger.
	 *
	 * @return void
	 */
	public static function download(string $archive_name, array $items_id): void
	{
		$files = [];
		$sql = 'SELECT item_path FROM {items} WHERE item_id IN (' . DB::inInt($items_id) . ')';
		if (DB::execute($sql))
		{
			foreach (DB::fetchCol('item_path') as &$f)
			{
				$files[] = CONF_ALBUMS_PATH . '/' . $f;
			}
			Zip::archive($archive_name, $files);
		}
	}

	/**
	 * Édite les informations, tags et dates de plusieurs fichiers.
	 *
	 * @param array $items_update
	 *   Informations de mise à jour.
	 *
	 * @return int
	 *   Retourne le nombre de fichiers affectés
	 *   ou -1 en cas d'erreur.
	 */
	public static function edit(array &$items_update): int
	{
		// Début de la transaction.
		if (!($in_transaction = DB::inTransaction()) && !DB::beginTransaction())
		{
			return -1;
		}

		// Récupération des informations des fichiers.
		$items_id = DB::inInt(array_keys($items_update));
		if (!DB::execute("SELECT * FROM {items} WHERE item_id IN ($items_id)"))
		{
			return DB::rollback(-1);
		}
		if (!$items_infos = DB::fetchAll('item_id'))
		{
			return DB::rollback(0);
		}

		// Récupération des tags de chaque fichier.
		$sql = "SELECT item_id,
					   tag_id,
					   tag_name
				  FROM {tags}
			 LEFT JOIN {tags_items} USING (tag_id)
				 WHERE item_id IN ($items_id)";
		if (!DB::execute($sql))
		{
			return DB::rollback(-1);
		}
		foreach (DB::fetchAll() as &$i)
		{
			$items_infos[$i['item_id']]['tags'][] = mb_strtolower($i['tag_name']);
		}

		// Dates futures.
		if (!is_array($dates_expiration = Config::$params['dates_expiration']))
		{
			$dates_expiration = [];
		}
		if (!is_array($dates_publication = Config::$params['dates_publication']))
		{
			$dates_publication = [];
		}

		// Mise à jour des informations des fichiers.
		$params = [];
		$tags_add = [];
		$tags_delete = [];
		$update_cat_lastpubdt = [];
		foreach ($items_update as $id => &$update)
		{
			if (!array_key_exists($id, $items_infos))
			{
				continue;
			}

			// Colonnes modifiables de la table {items}.
			$cols = ['crtdt', 'pubdt', 'expdt', 'name', 'url', 'path', 'desc',
				'commentable', 'downloadable', 'votable'];
			$p = [];
			foreach ($cols as &$name)
			{
				$p['item_' . $name] = $items_infos[$id]['item_' . $name];
			}
			$p_temp = $p;

			foreach ($update as $col => &$value)
			{
				// Tags.
				if ($col == 'tags')
				{
					$tags_str = Tags::sanitize((string) $value);
					$tags_array = explode(',', $tags_str);

					if (!isset($items_infos[$id]['tags']))
					{
						$items_infos[$id]['tags'] = [];
					}

					// Suppression de tags.
					else
					{
						$tags_array_lower = array_map('mb_strtolower', $tags_array);
						foreach ($items_infos[$id]['tags'] as &$tag)
						{
							if (!in_array($tag, $tags_array_lower))
							{
								$tags_delete[] = ['item_id' => $id, 'tag_name' => $tag];
							}
						}
					}

					// Ajout de nouveaux tags.
					if (mb_strlen($tags_str))
					{
						foreach ($tags_array as &$tag)
						{
							if (!in_array(mb_strtolower($tag), $items_infos[$id]['tags']))
							{
								$tags_add[] = ['item_id' => $id, 'tag_name' => $tag];
							}
						}
					}
				}

				// Colonnes de la table {items}.
				if (!array_key_exists($col, $p))
				{
					continue;
				}
				switch ($col)
				{
					// Titre et nom d'URL.
					case 'item_name' :
					case 'item_url' :
						$val = Utility::trimAll((string) $value);
						$val = $col == 'item_url' ? App::getURLName($val) : $val;
						$val = mb_substr($val, 0, 255);
						if (mb_strlen($val) > 0)
						{
							$p[$col] = $val;
						}
						break;

					// Description.
					case 'item_desc' :
						$val = Utility::trimAll((string) $value);
						$val = mb_substr($val, 0, 65535);
						$p[$col] = $val === '' ? NULL : $val;
						break;

					// Chemin du fichier.
					case 'item_path' :
						$item_path = self::_changeFilename($items_infos[$id], $value);
						if ($item_path === FALSE)
						{
							return DB::rollback(-1);
						}
						$p[$col] = $item_path;
						break;

					// Dates.
					case 'item_crtdt' :
					case 'item_expdt' :
					case 'item_pubdt' :
						$val = array_map(function($v)
						{
							return preg_match('`\D`', $v) ? 0 : $v;
						}, $value);
						$date = sprintf(
							"%'.04d-%'.02d-%'.02d %'.02d:%'.02d:%'.02d",
							$val['year'] ?? 0, $val['month'] ?? 0, $val['day'] ?? 0,
							$val['hour'] ?? 0, $val['minute'] ?? 0, $val['second'] ?? 0
						);
						$dt = explode('-', str_replace([' ', ':'], '-', $date));
						$p[$col] = ($dt[0] == '0000' || $dt[1] == '00' || $dt[2] == '00')
							? ($col == 'item_pubdt' ? $items_infos[$id][$col] : NULL)
							: $date;
						if (!$p[$col] || checkdate((int) $dt[1], (int) $dt[2], (int) $dt[0]))
						{
							if ($col == 'item_expdt'
							&& $p[$col] && $p[$col] > date('Y-m-d H:i:s')
							&& !in_array($p[$col], $dates_expiration))
							{
								$dates_expiration[] = $p[$col];
							}
							if ($col == 'item_pubdt'
							&& $p[$col] !== $items_infos[$id]['item_pubdt'])
							{
								if ($p[$col] && $p[$col] > date('Y-m-d H:i:s')
								&& !in_array($p[$col], $dates_publication))
								{
									$dates_publication[] = $p[$col];
								}
								$update_cat_lastpubdt[$items_infos[$id]['cat_id']] = 1;
							}
						}
						else
						{
							$p[$col] = $items_infos[$id][$col];
						}
						break;

					// Permissions.
					case 'item_commentable' :
					case 'item_downloadable' :
					case 'item_votable' :

						// On ne peut pas modifier la permission si
						// la permission parente est à 0.
						$parts = explode('_', $col);
						if ($update['parent_' . $parts[1]])
						{
							$p[$col] = (string) (int) $value;
						}
						else
						{
							$p[$col] = $items_infos[$id][$col];
						}
						break;
				}
			}

			if ($p !== $p_temp)
			{
				$p['item_id'] = $id;
				$params[] = $p;
			}
		}

		// Si aucune modification, inutile d'aller plus loin.
		if (!$params && !$tags_add && !$tags_delete)
		{
			return DB::rollback(0);
		}

		// Fichiers mis à jour par les tags.
		$items_tags_updated = 0;

		// Ajout de nouveaux tags.
		if ($tags_add)
		{
			$r = Tags::itemsAdd($tags_add);
			if ($r < 0)
			{
				return DB::rollback(-1);
			}

			$items_tags_updated += $r;
		}

		// Suppression de tags.
		if ($tags_delete)
		{
			$r = Tags::itemsDelete($tags_delete);
			if ($r < 0)
			{
				return DB::rollback(-1);
			}

			$items_tags_updated += $r;
		}

		// Mise à jour de la table {items}.
		if ($params)
		{
			$columns = '';
			foreach (array_keys($params[0]) as &$col)
			{
				$columns .= $col . ' = :' . $col . ', ';
			}
			$columns = substr($columns, 0, -2);

			if (!DB::execute("UPDATE {items} SET $columns WHERE item_id = :item_id", $params))
			{
				return DB::rollback(-1);
			}
		}

		// Mise à jour de la colonne 'cat_lastpubdt' des catégories parentes.
		if ($update_cat_lastpubdt)
		{
			// Récupération des identifiants des catégories parents.
			$sql = 'SELECT cat_id,
						   cat_parents
					  FROM {categories}
					 WHERE cat_id IN (' . DB::inInt(array_keys($update_cat_lastpubdt)) . ')';
			if (!DB::execute($sql))
			{
				return DB::rollback(-1);
			}
			$categories_id = [];
			foreach (DB::fetchAll() as &$i)
			{
				$categories_id = array_merge(
					$categories_id,
					explode(Parents::SEPARATOR, $i['cat_parents'] . $i['cat_id'])
				);
			}

			// Mise à jour des catégories.
			if (!Parents::updateInfos(array_values(array_unique($categories_id))))
			{
				return DB::rollback(-1);
			}

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

		// Mise à jour de la configuration si nécessaire.
		Config::changeDBParams([
			'dates_expiration' => $dates_expiration,
			'dates_publication' => $dates_publication
		]);

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

		return count($params) + $items_tags_updated;
	}

	/**
	 * Ajoute ou retire plusieurs fichiers des favoris d'un
	 * utilisateur (sans s'occuper de savoir si ce dernier
	 * possède les permissions d'accès à ces fichiers).
	 *
	 * @param array $items_id
	 *   Identifiant des fichiers.
	 * @param string $action
	 *   'add' ou 'remove'.
	 * @param int $user_id
	 *   Identifiant de l'utilisateur.
	 *
	 * @return int
	 *   Retourne le nombre de fichiers ajoutés ou retirés des favoris
	 *   ou -1 en cas d'erreur.
	 */
	public static function favorites(array $items_id, string $action, int $user_id): int
	{
		if (!$items_id || !in_array($action, ['add', 'remove']))
		{
			return 0;
		}

		// Début de la transaction.
		if (!($in_transaction = DB::inTransaction()) && !DB::beginTransaction())
		{
			return -1;
		}

		// On ne retient que les identifiants de fichiers
		// devant être ajoutés ou retirés des favoris, c'est
		// à dire uniquement ceux qui ne le sont pas déjà
		// (si on ajoute) ou uniquement ceux qui le sont déjà
		// (si on retire).
		$sql = 'SELECT item_id
				  FROM {favorites}
				 WHERE user_id = ?
				   AND item_id IN (' . DB::inInt($items_id) . ')';
		if (!DB::execute($sql, $user_id))
		{
			return DB::rollback(-1);
		}
		$favorites_items_id = DB::fetchCol('item_id');
		$items_id = array_flip($items_id);
		if ($action == 'add')
		{
			foreach ($favorites_items_id as &$id)
			{
				if (isset($items_id[$id]))
				{
					unset($items_id[$id]);
				}
			}
			$items_id = array_values(array_flip($items_id));
		}
		if ($action == 'remove')
		{
			$remove_items_id = [];
			foreach ($favorites_items_id as &$id)
			{
				if (isset($items_id[$id]))
				{
					$remove_items_id[] = $id;
				}
			}
			$items_id = $remove_items_id;
		}

		// Récupération des informations des fichiers.
		if (!$items_id || !($items_by_album = self::_getItemsByParent($items_id)))
		{
			return DB::rollback(0);
		}

		// On recrée la liste des identifiants de fichiers
		// à partir de $items_by_album pour écarter les fichiers
		// qui n'existent pas.
		$items_id = [];

		// On traite les fichiers album par album.
		$stat_change = $action == 'add' ? 1 : -1;
		foreach ($items_by_album as $cat_id => &$items_infos)
		{
			// Nombre de favoris à ajouter aux catégories parentes.
			$a_diff = $d_diff = 0;
			foreach ($items_infos as &$i)
			{
				$items_id[] = $i['item_id'];
				${$i['item_status'] == '1' ? 'a_diff' : 'd_diff'} += $stat_change;
			}

			// On met à jour les catégories parentes si nécessaire.
			if ($a_diff + $d_diff != 0)
			{
				$parents_id = Category::getParentsIdArray(
					current($items_infos)['cat_parents'], $cat_id
				);
				$sql = "UPDATE {categories}
						   SET cat_a_favorites = cat_a_favorites + $a_diff,
							   cat_d_favorites = cat_d_favorites + $d_diff
						 WHERE cat_id IN (" . DB::inInt($parents_id) . ")";
				if (!DB::execute($sql))
				{
					return DB::rollback(-1);
				}
			}
		}

		// On met à jour les fichiers.
		$sql = "UPDATE {items}
				   SET item_favorites = item_favorites + $stat_change
			     WHERE item_id IN (" . DB::inInt($items_id) . ")";
		if (!DB::execute($sql))
		{
			return DB::rollback(-1);
		}

		// On ajoute ou retire les fichiers des favoris de l'utilisateur.
		$sql = $action == 'add'
			? "INSERT IGNORE INTO {favorites}
				(user_id, item_id, fav_date) VALUES ($user_id, ?, NOW())"
			: "DELETE FROM {favorites} WHERE user_id = $user_id AND item_id = ?";
		if (!DB::execute($sql, array_map(function($v){return[$v];}, $items_id)))
		{
			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 (!$in_transaction && !DB::commitTransaction())
		{
			return DB::rollback(-1);
		}

		return count($items_id);
	}

	/**
	 * Retourne les informations de fichiers qui se trouvent
	 * en plusieurs exemplaires identiques dans la galerie.
	 *
	 * @return array
	 */
	public static function getDuplicate(): array
	{
		$sql = 'SELECT item_id,
					   item_type,
					   item_filesize,
					   item_width,
					   item_height,
					   item_path
				  FROM {items}';
		if (!DB::execute($sql))
		{
			return [];
		}
		$items = DB::fetchAll('item_id');
		$items_hash = [];
		$duplicate = [];
		foreach ($items as $id => &$i)
		{
			$hash = md5(((int) $i['item_type'])  . '|' . ((int) $i['item_filesize'])
				. '|' . ((int) $i['item_width']) . '|' . ((int) $i['item_height']));
			if (isset($items_hash[$hash]))
			{
				$original_file = CONF_ALBUMS_PATH . '/' . $items[$items_hash[$hash]]['item_path'];
				$file = CONF_ALBUMS_PATH . '/' . $items[$id]['item_path'];
				if (file_exists($original_file) && file_exists($file)
				&& ($file_hash = md5_file($file)) === md5_file($original_file))
				{
					$duplicate[$file_hash][$items_hash[$hash]] = $items[$items_hash[$hash]];
					$duplicate[$file_hash][$id] = $items[$id];
				}
			}
			else
			{
				$items_hash[$hash] = $id;
			}
		}
		$items = $items_hash = NULL;
		return array_values(array_map('array_values', $duplicate));
	}

	/**
	 * Retourne un timestamp à partir duquel des fichiers
	 * ajoutés à la galerie sont considérés comme récents.
	 *
	 * @return int
	 */
	public static function getRecentTimestamp(): int
	{
		return ((int) $_SERVER['REQUEST_TIME'])
			- (86400 * (int) Config::$params['items_recent_days']);
	}

	/**
	 * Retourne l'état (activé ou désactivé) d'un fichier,
	 * c'est à dire soit :
	 *    - '0' si la colonne item_status est à '-1' ou à '0'
	 *    - '1' si la colonne item_status est à '1'
	 *
	 * @param string $item_status
	 *
	 * @return string
	 */
	public static function getStatus(string $item_status): string
	{
		return $item_status == '-1' ? '0' : $item_status;
	}

	/**
	 * Retourne le titre d'une image/video à partir de son nom de fichier.
	 *
	 * @param string $filename
	 *
	 * @return string
	 */
	public static function getTitle(string $filename): string
	{
		$title = str_replace('_', ' ', $filename);
		$title = preg_replace('`^(.+)\.[^\.]+$`', '$1', $title);
		$title = Utility::trimAll($title);
		$title = Utility::UTF8($title);

		if ($title === '')
		{
			$title = '?';
		}

		return $title;
	}

	/**
	 * Retourne un code numérique correspondant au type d'un fichier.
	 *
	 * @param string $f
	 *   Chemin absolu du fichier.
	 * @param string $mime
	 *   Type MIME du fichier fourni par cette méthode.
	 * @param bool $mime_only
	 *   Analyser uniquement le type MIME du fichier ?
	 *   Si FALSE, prend éventuellement en compte l'extension de fichier.
	 *
	 * @return int
	 */
	public static function getTypeCode(string $f, &$mime = '', bool $mime_only = FALSE): int
	{
		switch ($mime = File::getMimeType($f))
		{
			case 'image/avif' :
				return self::TYPE_AVIF;

			case 'image/gif' :
				return File::isAnimatedGIF($f) ? self::TYPE_GIF_ANIMATED : self::TYPE_GIF;

			case 'image/jpeg' :
				return self::TYPE_JPEG;

			case 'image/png' :
				return File::isAnimatedPNG($f) ? self::TYPE_PNG_ANIMATED : self::TYPE_PNG;

			case 'image/webp' :
				return File::isAnimatedWEBP($f) ? self::TYPE_WEBP_ANIMATED : self::TYPE_WEBP;

			case 'video/mp4' :
			case 'video/x-m4v' :
				return self::TYPE_MP4;

			case 'video/webm' :
				return self::TYPE_WEBM;
		}

		if (!$mime_only)
		{
			switch (strtolower(preg_replace('`^.+\.([a-z0-9]+)$`i', '$1', $f)))
			{
				case 'avif' :
					return self::TYPE_AVIF;

				case 'gif' :
					return self::TYPE_GIF;

				case 'jfif' :
				case 'jpg' :
				case 'jpeg' :
					return self::TYPE_JPEG;

				case 'mp4' :
					return self::TYPE_MP4;

				case 'png' :
					return self::TYPE_PNG;

				case 'webm' :
					return self::TYPE_WEBM;

				case 'webp' :
					return self::TYPE_WEBP;
			}
		}

		return 0;
	}

	/**
	 * Retourne le type de fichier PHP correspondant
	 * à l'extension d'un fichier.
	 *
	 * @param string $ext
	 *
	 * @return int
	 */
	public static function getTypeFile(string $ext): int
	{
		switch ($ext)
		{
			case 'avif' :
				return IMAGETYPE_AVIF;

			case 'gif' :
				return IMAGETYPE_GIF;

			case 'png' :
				return IMAGETYPE_PNG;

			case 'webp' :
				return IMAGETYPE_WEBP;

			default :
				return IMAGETYPE_JPEG;
		}
	}

	/**
	 * Retourne le type MIME correspondant au type de fichier.
	 *
	 * @param mixed $type
	 *
	 * @return string
	 */
	public static function getTypeMime($type): string
	{
		switch ($type)
		{
			case self::TYPE_AVIF :
				return 'image/avif';

			case self::TYPE_GIF :
			case self::TYPE_GIF_ANIMATED :
				return 'image/gif';

			case self::TYPE_JPEG :
				return 'image/jpeg';

			case self::TYPE_MP4 :
				return 'video/mp4';

			case self::TYPE_PNG :
			case self::TYPE_PNG_ANIMATED :
				return 'image/png';

			case self::TYPE_WEBM :
				return 'video/webm';

			case self::TYPE_WEBP :
			case self::TYPE_WEBP_ANIMATED :
				return 'image/webp';
		};

		return '';
	}

	/**
	 * Retourne la liste de tous les formats de fichiers pris en charge
	 * par l'application, ainsi que les extensions et types MIME autorisés.
	 *
	 * @param string $type
	 *   Type de fichiers : 'image' ou 'video'.
	 *   Si non fourni, retourne tous les types de fichiers.
	 *
	 * @return array
	 */
	public static function getTypesSupported(string $type = ''): array
	{
		switch ($type)
		{
			case 'image' :
				$file_exts = Image::EXT_ARRAY;
				$mime_types = Item::IMAGE_TYPES;
				break;

			case 'video' :
				$file_exts = Video::EXT_ARRAY;
				$mime_types = Item::VIDEO_TYPES;
				break;

			default :
				$file_exts = array_merge_recursive(Image::EXT_ARRAY, Video::EXT_ARRAY);
				$mime_types = array_merge_recursive(
					Item::IMAGE_TYPES, Item::VIDEO_TYPES
				);
		}

		$avif = function_exists('imageavif');
		$types = ['app' => [], 'ext' => [], 'mime' => [], 'text' => []];

		foreach ($mime_types as $code)
		{
			if ($code == Item::TYPE_AVIF && !$avif)
			{
				continue;
			}
			$types['app'][] = $code;
		}
		foreach ($file_exts as $ext)
		{
			if ($ext == 'avif' && !$avif)
			{
				continue;
			}
			$types['ext'][] = $ext;
		}
		foreach (array_unique(array_map(['Item', 'getTypeMime'], $mime_types)) as $mime)
		{
			if (($file = substr($mime, 6)) == 'avif' && !$avif)
			{
				continue;
			}
			$types['mime'][] = $mime;
			$types['text'][] = strtoupper($file);
		}

		sort($types['text']);

		return $types;
	}

	/**
	 * Retourne un texte localisé correspondant au type de fichier.
	 *
	 * @param mixed $type
	 *
	 * @return string
	 */
	public static function getTypeText($type): string
	{
		switch ($type)
		{
			case self::TYPE_AVIF :
				return sprintf(__('Image %s'), 'AVIF');

			case self::TYPE_GIF :
				return sprintf(__('Image %s'), 'GIF');

			case self::TYPE_GIF_ANIMATED :
				return sprintf(__('Image %s animée'), 'GIF');

			case self::TYPE_JPEG :
				return sprintf(__('Image %s'), 'JPEG');

			case self::TYPE_MP4 :
				return sprintf(__('Vidéo %s'), 'MP4');

			case self::TYPE_PNG :
				return sprintf(__('Image %s'), 'PNG');

			case self::TYPE_PNG_ANIMATED :
				return sprintf(__('Image %s animée'), 'PNG');

			case self::TYPE_WEBM :
				return sprintf(__('Vidéo %s'), 'WebM');

			case self::TYPE_WEBP :
				return sprintf(__('Image %s'), 'WebP');

			case self::TYPE_WEBP_ANIMATED :
				return sprintf(__('Image %s animée'), 'WebP');
		};

		return '?';
	}

	/**
	 * Incrémente le nombre de vues d'un fichier.
	 *
	 * @param int $item_id
	 *
	 * @return int
	 *   Retourne 1 si le nombre de vues du fichier a été incrémenté,
	 *   0 dans le cas contraire, ou -1 en cas d'erreur.
	 */
	public static function hit(int $item_id): int
	{
		// Ne pas comptabiliser les vues des administrateurs ?
		if (Auth::$isAdmin && Config::$params['views_admin'])
		{
			return 0;
		}

		// Le fichier a-t-il déjà été vu par l'utilisateur ?
		if (Auth::$prefs->read('last_item') == $item_id)
		{
			return 0;
		}

		// Ne pas comptabiliser les vues en fonction de l'IP ?
		if (Config::$params['views_ip'] && isset($_SERVER['REMOTE_ADDR']))
		{
			if (Utility::listSearch((string) $_SERVER['REMOTE_ADDR'],
			Config::$params['views_ip_list']))
			{
				return 0;
			}
		}

		// Ne pas comptabiliser les vues en fonction de l'User-Agent ?
		if (Config::$params['views_useragent'] && isset($_SERVER['HTTP_USER_AGENT']))
		{
			if (Utility::listSearch((string) $_SERVER['HTTP_USER_AGENT'],
			Config::$params['views_useragent_list'], TRUE))
			{
				return 0;
			}
		}

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

		// Récupération des identifiants des catégories parentes du fichier.
		$sql = 'SELECT cat_id,
					   cat_parents
				  FROM {categories}
			 LEFT JOIN {items} USING (cat_id)
				 WHERE item_id = ?
				   AND item_status = "1"';
		if (!DB::execute($sql, $item_id))
		{
			return DB::rollback(-1);
		}
		if (!$cat_infos = DB::fetchRow())
		{
			return 0;
		}

		// On incrémente le nombre de vues du fichier.
		$sql = 'UPDATE {items} SET item_hits = item_hits + 1 WHERE item_id = ?';
		if (!DB::execute($sql, $item_id))
		{
			return DB::rollback(-1);
		}

		// On incrémente le nombre de vues des catégories parentes.
		$cat_ids = DB::inInt(
			Category::getParentsIdArray($cat_infos['cat_parents'], $cat_infos['cat_id'])
		);
		$sql = "UPDATE {categories} SET cat_a_hits = cat_a_hits + 1 WHERE cat_id IN ($cat_ids)";
		if (!DB::execute($sql))
		{
			return DB::rollback(-1);
		}

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

		Auth::$prefs->add('last_item', (string) $item_id);
		return 1;
	}

	/**
	 * Détermine si le type d'un fichier correspond à une image.
	 *
	 * @param mixed $type
	 *
	 * @return bool
	 */
	public static function isImage($type): bool
	{
		$type = (string) (int) $type;

		return $type[0] === '1';
	}

	/**
	 * Détermine si le type d'un fichier correspond à une vidéo.
	 *
	 * @param mixed $type
	 *
	 * @return bool
	 */
	public static function isVideo($type): bool
	{
		$type = (string) (int) $type;

		return $type[0] === '2';
	}

	/**
	 * Déplace des fichiers vers un autre album.
	 *
	 * @param array $items_id
	 *   Identifiant des fichiers.
	 * @param int $dest_id
	 *   Identifiant de l'album destination.
	 *
	 * @return int
	 *   Retourne le nombre de fichiers affectés
	 *   ou -1 en cas d'erreur.
	 */
	public static function move(array $items_id, int $dest_id): int
	{
		if (!$items_id)
		{
			return 0;
		}

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

		// Récupération des informations de l'album destination.
		$sql = 'SELECT cat_status,
					   cat_path,
					   cat_parents
				  FROM {categories}
				 WHERE cat_id = ?
				   AND cat_filemtime IS NOT NULL';
		if (!DB::execute($sql, $dest_id))
		{
			return DB::rollback(-1);
		}
		if (!$dest_infos = DB::fetchRow())
		{
			return DB::rollback(0);
		}
		$dest_path = $dest_infos['cat_path'];

		// Récupération des informations des fichiers.
		if (!($items_by_album = self::_getItemsByParent($items_id)))
		{
			return DB::rollback(0);
		}

		// On traite les fichiers album par album.
		$items_moved = 0;
		$update_infos = [];
		foreach ($items_by_album as $cat_id => &$items_infos)
		{
			// Si l'album destination est le même que l'album source,
			// on passe aux albums suivants.
			if ($cat_id == $dest_id)
			{
				continue;
			}

			// On calcule les valeurs des colonnes
			// des catégories parentes à mettre à jour.
			$stats =
			[
				'albums' => 0,
				'comments' => 0,
				'favorites' => 0,
				'hits' => 0,
				'images' => 0,
				'rating' => 0,
				'size' => 0,
				'videos' => 0,
				'votes' => 0
			];
			$cat_update = ['a' => $stats, 'd' => $stats];
			$params = [];
			foreach ($items_infos as &$i)
			{
				$status = $i['item_status'] == '1' ? 'a' : 'd';

				// On recalcule les stats.
				self::_parentsUpdateStats($cat_update[$status], $i);

				// Informations pour le déplacement du fichier.
				$old_path = $i['item_path'];
				$new_path = $dest_path . '/' . basename($i['item_path']);
				$new_path = File::getSecureFilename($new_path, CONF_ALBUMS_PATH . '/');
				$rename[] = [$old_path, $new_path];

				// Paramètres de la requête.
				$params[] = [$dest_id, $new_path, $i['item_id']];
			}

			// On met à jour la base de données.
			$sql = 'UPDATE {items} SET cat_id = ?, item_path = ? WHERE item_id = ?';
			if (!DB::execute($sql, $params))
			{
				return DB::rollback(-1);
			}
			$items_moved += DB::rowCount();

			// Mise à jour des statistiques des catégories parentes.
			$source_parents_ids = Category::getParentsIdArray(
				current($items_infos)['cat_parents'], $cat_id
			);
			$dest_parents_ids = Category::getParentsIdArray($dest_infos['cat_parents'], $dest_id);
			$update_infos += array_flip($source_parents_ids);
			$update_infos += array_flip($dest_parents_ids);
			if (!Parents::updateStats($cat_update, '-', '-', $source_parents_ids)
			 || !Parents::updateStats($cat_update, '+', '+', $dest_parents_ids))
			{
				return DB::rollback(-1);
			}
		}

		if (!$items_moved)
		{
			return DB::rollback(0);
		}

		// On met à jour la date de publication du dernier fichier
		// et la vignette des catégories parentes.
		if (!Parents::updateInfos(array_keys($update_infos)))
		{
			return DB::rollback(-1);
		}

		// On met à jour le nombre de sous-catégories.
		if (!Parents::updateSubCats(array_keys($update_infos)))
		{
			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);
		}

		// On déplace les fichiers.
		foreach ($rename as &$f)
		{
			$old_path = CONF_ALBUMS_PATH . '/' . $f[0];
			$new_path = CONF_ALBUMS_PATH . '/' . $f[1];
			if (!file_exists($old_path))
			{
				continue;
			}
			if (is_dir(dirname($new_path)))
			{
				File::rename($old_path, $new_path);
			}
		}

		return $items_moved;
	}

	/**
	 * Change le propriétaire de plusieurs fichiers.
	 *
	 * @param array $items_id
	 *   Identifiant des fichiers.
	 * @param int $user_id
	 *   Identifiant du nouveau propriétaire.
	 *
	 * @return int
	 *   Retourne le nombre de fichiers affectés
	 *   ou -1 en cas d'erreur.
	 */
	public static function owner(array $items_id, int $user_id): int
	{
		if (!$items_id || $user_id == 2)
		{
			return 0;
		}

		// On vérifie que l'utilisateur existe.
		if (!DB::execute('SELECT COUNT(*) FROM {users} WHERE user_id = ?', $user_id))
		{
			return -1;
		}
		if (DB::fetchVal() < 1)
		{
			return 0;
		}

		// Changement de propriétaire pour les fichiers.
		foreach ($items_id as &$id)
		{
			$params[] = ['item_id' => (int) $id, 'user_id' => (int) $user_id];
		}
		$sql = "UPDATE {items}
				   SET user_id = :user_id
				 WHERE item_id = :item_id
				   AND user_id != :user_id";
		if (!DB::execute($sql, $params))
		{
			return -1;
		}

		return DB::rowCount();
	}

	/**
	 * Active ou désactive des fichiers en fonction
	 * des dates de publication et d'expiration.
	 *
	 * @return void
	 */
	public static function pubexp(): void
	{
		foreach (['publication' => 'pubdt', 'expiration' => 'expdt'] as $conf => $col)
		{
			if (empty(Config::$params['dates_' . $conf])
			|| !is_array($dates = Config::$params['dates_' . $conf]))
			{
				continue;
			}

			// On sélectionne les dates antérieures à la date actuelle.
			$selected = [];
			foreach ($dates as &$dt)
			{
				if (preg_match('`^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$`', $dt)
				&& $dt < date('Y-m-d H:i:s'))
				{
					$selected[] = $dt;
				}
			}
			if (!$selected)
			{
				continue;
			}

			// Récupération de l'identifiant de tous les fichiers
			// correspondants aux dates sélectionnées.
			$sql = "SELECT item_id
					  FROM {items}
					 WHERE item_$col IN (" . DB::inParams($selected) . ")";
			if (!DB::execute($sql, $selected))
			{
				continue;
			}

			// Changement d'état des fichiers.
			if (self::status(DB::fetchAll('item_id', 'item_id'), $col == 'pubdt' ? 1 : 0) < 0)
			{
				continue;
			}

			// Suppression des dates sélectionnées dans la configuration.
			$dates = [];
			foreach (Config::$params['dates_' . $conf] as &$dt)
			{
				if (!in_array($dt, $selected))
				{
					$dates[] = $dt;
				}
			}
			if ($dates != Config::$params['dates_' . $conf])
			{
				Config::changeDBParams(['dates_' . $conf => $dates]);
			}
		}
	}

	/**
	 * Change l'état (activé, désactivé) de plusieurs fichiers.
	 *
	 * @param array $items_id
	 *   Identifiant des fichiers.
	 * @param int $status
	 *   État (0 ou 1).
	 * @param bool $reset_item_pubdt
	 *   Mettre à jour la date de publication des fichiers ?
	 *
	 * @return int
	 *   Retourne le nombre de fichiers affectés
	 *   ou -1 en cas d'erreur.
	 */
	public static function status(array $items_id, int $status,
	bool $reset_item_pubdt = FALSE): int
	{
		if (!$items_id || $status < 0 || $status > 1)
		{
			return 0;
		}

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

		// Récupération des informations des fichiers.
		if (!($items_by_album = self::_getItemsByParent($items_id)))
		{
			return DB::rollback(0);
		}

		// On traite les fichiers album par album.
		$nb_items = 0;
		$update_infos = [];
		$update_parents_id = [];
		$albums_notify = [];
		foreach ($items_by_album as $cat_id => &$items_infos)
		{
			// On calcule les valeurs des colonnes
			// des catégories parentes à mettre à jour.
			$cat_update =
			[
				'albums' => 0,
				'comments' => 0,
				'favorites' => 0,
				'hits' => 0,
				'images' => 0,
				'rating' => 0,
				'size' => 0,
				'videos' => 0,
				'votes' => 0
			];
			foreach ($items_infos as &$i)
			{
				// Si l'état du fichier est le même, on l'ignore.
				if (self::getStatus($i['item_status']) == $status)
				{
					continue;
				}

				if ($status && $i['item_status'] == '-1')
				{
					$albums_notify[$i['cat_id']] = $i['cat_path'];
				}

				// On recalcule les stats.
				self::_parentsUpdateStats($cat_update, $i);
			}

			// Si aucun fichier n'est à mettre à jour,
			// on arrête là pour l'album courant.
			if ($cat_update['images'] + $cat_update['videos'] == 0)
			{
				continue;
			}

			// Informations de l'album.
			$cat_infos = current($items_infos);

			// Mise à jour des fichiers.
			if ($status == 1)
			{
				$sql_update = ['item_status = "1"'];
				$sql_update[] = $reset_item_pubdt
					? 'item_pubdt = NOW()'
					: 'item_pubdt = CASE
						   WHEN item_pubdt IS NULL THEN NOW()
						   ELSE item_pubdt END';
			}
			else
			{
				$sql_update = ['item_status = CASE
						   WHEN item_status = "-1" THEN "-1"
						   ELSE "0" END'];
			}
			$sql_not_status = $status == 1 ? "'1'" : "'-1', '0'";
			$sql = "UPDATE {items}
					   SET " . implode(', ', $sql_update) . "
					 WHERE item_id IN (" . DB::inInt(array_keys($items_infos)) . ")
					   AND item_status NOT IN ($sql_not_status)";
			if (!DB::execute($sql))
			{
				return DB::rollback(-1);
			}
			$nb_items += DB::rowCount();

			// Mise à jour des statistiques des catégories parentes.
			$cat_update = ['a' => $cat_update, 'd' => $cat_update];
			if ($status)
			{
				$a_sign = '+';
				$d_sign = '-';
			}
			else
			{
				$a_sign = '-';
				$d_sign = '+';
			}
			$parents_id = Category::getParentsIdArray($cat_infos['cat_parents']);
			$update_parents_id += array_flip($parents_id);
			$parents_id[] = $cat_id;
			$update_infos += array_flip($parents_id);
			if (!Parents::updateStats($cat_update, $a_sign, $d_sign, $parents_id))
			{
				return DB::rollback(-1);
			}
		}

		if (!$update_parents_id)
		{
			return DB::rollback(0);
		}

		// On met à jour la date de publication du dernier fichier
		// et la vignette des catégories parentes.
		if (!Parents::updateInfos(array_keys($update_infos)))
		{
			return DB::rollback(-1);
		}

		// On met à jour le nombre de sous-catégories.
		if (!Parents::updateSubCats(array_keys($update_parents_id)))
		{
			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);
		}

		// Notification par courriel pour les fichiers
		// qui ont été activés et qui n'avaient jamais été publiés.
		if ($albums_notify)
		{
			$mail = new Mail();
			$mail->notify(
				'items',
				$albums_notify,
				Auth::$id,
				[
					'user_id' => Auth::$id,
					'user_name' => Auth::$nickname
				]
			);
			$mail->send();
		}

		return $nb_items;
	}

	/**
	 * Met à jour les informations de plusieurs fichiers en base de données.
	 *
	 * @param array $items_id
	 *   Identifiant des fichiers.
	 *
	 * @return int
	 *   Retourne le nombre de fichiers affectés
	 *   ou -1 en cas d'erreur.
	 */
	public static function update(array $items_id): int
	{
		// Récupération des informations des fichiers.
		if (!$items_id || !($items_by_album = self::_getItemsByParent($items_id)))
		{
			return 0;
		}

		// On traite les fichiers album par album.
		$items_updated = 0;
		foreach ($items_by_album as $cat_id => &$items_infos)
		{
			// Chemin d'accès des fichiers.
			$items_path = [];
			foreach ($items_infos as $item_id => &$i)
			{
				$items_path[basename($i['item_path'])] = 1;
			}

			// Initialisation du scan.
			$scan = new Scan();
			if (!$scan->getInit)
			{
				return -1;
			}

			// Options de scan.
			$scan->setForcedScan = TRUE;
			$scan->setHttp = TRUE;
			$scan->setHttpFiles = $items_path;
			$scan->setMailAlert = FALSE;
			$scan->setUpdateFiles = TRUE;

			// Lancement du scan.
			if ($scan->start(dirname($items_infos[$item_id]['item_path'])) === FALSE)
			{
				return -1;
			}

			$items_updated += $scan->getReport['images_updated']
							+ $scan->getReport['videos_updated'];
		}

		return $items_updated;
	}

	/**
	 * Met à jour le nombre de favoris de tous les fichiers
	 * et de toutes leurs catégories parentes mis en favoris
	 * par des utilisateurs en fonction de leur nouvel état.
	 *
	 * Par exemple, si un fichier a été mis 3 fois en favoris
	 * (colonne item_favorites à 3) et que l'on désactive un
	 * utilisateur qui a mis ce fichier dans ses favoris,
	 * alors on va mettre à jour la colonne item_favorites à 2,
	 * et faire la même chose pour l'album et les catégories
	 * parentes dans lequels se trouve ce fichier.
	 *
	 * Cette méthode doit obligatoirement être exécutée dans
	 * une transaction.
	 *
	 * @param array $users_id
	 *   Identifiant des utilisateurs.
	 * @param int $status
	 *   Nouvel état des utilisateurs
	 *   (1 = activé ou 0 = désactivé/supprimé).
	 *
	 * @return bool
	 */
	public static function updateFavoritesCount(array $users_id, int $status): bool
	{
		if (!in_array($status, [0, 1]) || !DB::inTransaction())
		{
			return FALSE;
		}

		// Récupération de tous les fichiers mis en favoris par les utilisateurs.
		$sql = 'SELECT item_id FROM {favorites} WHERE user_id IN (' . DB::inInt($users_id) . ')';
		if (!DB::execute($sql))
		{
			return FALSE;
		}
		if (!$items_id = DB::fetchAll())
		{
			return TRUE;
		}

		// On crée un tableau associant l'identifiant de chaque fichier
		// avec le nombre de fois où il a été mis en favoris.
		$items_favorites = [];
		foreach ($items_id as &$i)
		{
			if (array_key_exists($i['item_id'], $items_favorites))
			{
				$items_favorites[$i['item_id']]++;
			}
			else
			{
				$items_favorites[$i['item_id']] = 1;
			}
		}

		// On met à jour la colonne "item_favorites" de la table {items}.
		$o = $status ? '+' : '-';
		$params = [];
		foreach ($items_favorites as $item_id => &$favorites_count)
		{
			$params[] = [$favorites_count, $item_id];
		}
		$sql = "UPDATE {items} SET item_favorites = item_favorites $o ? WHERE item_id = ?";
		if (!DB::execute($sql, $params))
		{
			return FALSE;
		}

		// On met à jour le nombre de favoris des catégories parentes.
		if (!($items_by_album =
		self::_getItemsByParent(array_keys($items_favorites), 'item_id, item_status')))
		{
			return TRUE;
		}
		foreach ($items_by_album as $cat_id => &$items_infos)
		{
			// Nombre de favoris à ajouter aux catégories parentes.
			$a_favorites = $d_favorites = 0;
			foreach ($items_infos as &$i)
			{
				${$i['item_status'] == '1' ? 'a_favorites' : 'd_favorites'}
					+= $items_favorites[$i['item_id']];
			}

			// On met à jour les catégories parentes.
			$parents_id = Category::getParentsIdArray(
				current($items_infos)['cat_parents'], $cat_id
			);
			$sql = "UPDATE {categories}
					   SET cat_a_favorites = cat_a_favorites $o $a_favorites,
						   cat_d_favorites = cat_d_favorites $o $d_favorites
					 WHERE cat_id IN (" . DB::inInt($parents_id) . ")";
			if (!DB::execute($sql))
			{
				return FALSE;
			}
		}

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

		return TRUE;
	}

	/**
	 * Détermine quels fichiers de $items sont présents dans
	 * les favoris de l'utilisateur.
	 * On effectue une requête séparée plutôt qu'une sous-requête
	 * car c'est beaucoup plus rapide.
	 *
	 * @param array $items
	 *
	 * @return void
	 */
	public static function userFavorites(array &$items): void
	{
		if (!$items || !Auth::$connected
		|| !Config::$params['users'] || !Config::$params['favorites'])
		{
			return;
		}
		$sql = 'SELECT item_id
				  FROM {favorites}
				 WHERE item_id IN (' . DB::inInt(array_column($items, 'item_id')) . ')
				   AND user_id = ?';
		if (!DB::execute($sql, Auth::$id))
		{
			return;
		}
		if ($items_id = DB::fetchAll('item_id', 'item_id'))
		{
			foreach ($items as &$i)
			{
				$i['in_favorites'] = (int) in_array($i['item_id'], $items_id);
			}
		}
	}

	/**
	 * Détermine la note de l'utilisateur pour les fichiers $items
	 * On effectue une requête séparée plutôt qu'une sous-requête
	 * car c'est beaucoup plus rapide.
	 *
	 * @param array $items
	 *
	 * @return void
	 */
	public static function userRating(array &$items): void
	{
		if (!$items || !Config::$params['votes'])
		{
			return;
		}
		$sql = 'SELECT item_id,
					   vote_rating
				  FROM {votes}
				 WHERE item_id IN (' . DB::inInt(array_column($items, 'item_id')) . ')';
		$params = [];
		if (Config::$params['users'])
		{
			$sql .= 'AND user_id = :user_id';
			$params['user_id'] = Auth::$id;
		}
		else
		{
			$sql .= 'AND user_id = 2
					 AND vote_cookie = :vote_cookie';
			$params['vote_cookie'] = Auth::$prefs->read('rating');
		}
		if (!DB::execute($sql, $params))
		{
			return;
		}
		if ($items_id_rating = DB::fetchAll('item_id', 'vote_rating'))
		{
			foreach ($items as &$i)
			{
				$i['rating'] = $items_id_rating[$i['item_id']] ?? 0;
			}
		}
	}

	/**
	 * Détermine quels fichiers de $items sont présents dans
	 * la sélection de l'utilisateur.
	 * On effectue une requête séparée plutôt qu'une sous-requête
	 * car c'est beaucoup plus rapide.
	 *
	 * @param array $items
	 *
	 * @return void
	 */
	public static function userSelection(array &$items): void
	{
		if (!$items || !Selection::isActivated())
		{
			return;
		}
		if (Auth::$connected)
		{
			$sql = 'SELECT item_id
					  FROM {selection}
					 WHERE item_id IN (' . DB::inInt(array_column($items, 'item_id')) . ')
					   AND user_id = ?';
			if (!DB::execute($sql, Auth::$id))
			{
				return;
			}
			$items_id = DB::fetchAll('item_id', 'item_id');
		}
		else
		{
			$items_id = Selection::getCookieItems();
		}
		if ($items_id)
		{
			foreach ($items as &$i)
			{
				$i['in_selection'] = (int) in_array($i['item_id'], $items_id);
			}
		}
	}

	/**
	 * Change le nombre de vues de plusieurs fichiers.
	 *
	 * @param array $items_id
	 *   Identifiant des fichiers.
	 * @param int $hits
	 *   Nombre de vues.
	 *
	 * @return int
	 *   Retourne le nombre de fichiers affectés
	 *   ou -1 en cas d'erreur.
	 */
	public static function views(array $items_id, int $hits): int
	{
		if (!$items_id || $hits < 0)
		{
			return 0;
		}

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

		// Récupération des informations des fichiers.
		if (!($items_by_album = self::_getItemsByParent($items_id)))
		{
			return DB::rollback(0);
		}

		// On traite les fichiers album par album.
		foreach ($items_by_album as $cat_id => &$items_infos)
		{
			// Nombre de vues à ajouter aux catégories parentes.
			$a_diff = $d_diff = 0;
			foreach ($items_infos as &$i)
			{
				${$i['item_status'] == '1' ? 'a_diff' : 'd_diff'}
					+= $hits - (int) $i['item_hits'];
			}

			// On met à jour les catégories parentes si nécessaire.
			if ($a_diff + $d_diff != 0)
			{
				$parents_id = Category::getParentsIdArray(
					current($items_infos)['cat_parents'], $cat_id
				);
				$sql = "UPDATE {categories}
						   SET cat_a_hits = cat_a_hits + $a_diff,
							   cat_d_hits = cat_d_hits + $d_diff
						 WHERE cat_id IN (" . DB::inInt($parents_id) . ")";
				if (!DB::execute($sql))
				{
					return DB::rollback(-1);
				}
			}
		}

		// On met à jour les fichiers.
		$sql = "UPDATE {items}
				   SET item_hits = $hits
			     WHERE item_id IN (" . DB::inInt($items_id) . ")
			       AND item_hits != $hits";
		if (!DB::execute($sql))
		{
			return DB::rollback(-1);
		}
		$items_updated = DB::rowCount();

		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 $items_updated;
	}



	/**
	 * Modification du nom de fichier.
	 *
	 * @param array $i
	 *   Informations du fichier.
	 * @param string $new_filename
	 *   Nouveau nom de fichier.
	 *
	 * @return mixed
	 *   Retourne le nouveau nom de fichier,
	 *   ou FALSE si une erreur est survenue.
	 */
	private static function _changeFilename(array &$i, string $new_filename)
	{
		// Nouveau nom de fichier.
		$new_filename = substr($new_filename, 0, 220);
		$parent_path = dirname($i['item_path']);
		$parent_abs_path =  CONF_ALBUMS_PATH . '/' .
			($parent_path == '.' ? '' : $parent_path . '/');

		$new_filename = App::getValidFilename($new_filename);
		if ($new_filename === '' || $new_filename == basename($i['item_path']))
		{
			return $i['item_path'];
		}

		$new_filename = File::getSecureFilename($new_filename, $parent_abs_path);
		$new_path = dirname($i['item_path']) . '/' . $new_filename;
		if ($new_path == $i['item_path'])
		{
			return $i['item_path'];
		}

		// Vérification de l'extension.
		if ((preg_match('`.\.(' . implode('|', Video::EXT_ARRAY) . ')$`i', $i['item_path'])
		 && !preg_match('`.\.(' . implode('|', Video::EXT_ARRAY) . ')$`i', $new_path))
		 || (preg_match('`.\.(' . implode('|', Image::EXT_ARRAY) . ')$`i', $i['item_path'])
		 && !preg_match('`.\.(' . implode('|', Image::EXT_ARRAY) . ')$`i', $new_path)))
		{
			return $i['item_path'];
		}

		$current_abs_path = $parent_abs_path . basename($i['item_path']);
		$new_abs_path = $parent_abs_path . basename($new_path);
		if (!File::rename($current_abs_path, $new_abs_path))
		{
			return FALSE;
		}

		return $new_path;
	}

	/**
	 * Retourne les informations d'une liste de fichiers.
	 *
	 * @param array $items_id
	 *   Identifiants des fichiers.
	 * @param string $cols
	 *   Colonnes à récupérer.
	 *
	 * @return array
	 */
	private static function _getItemsByParent(array $items_id, string $cols = 'i.*'): array
	{
		$sql = "SELECT $cols,
					   cat.cat_id,
					   cat.cat_path,
					   cat.cat_parents,
					   cat.cat_status
				  FROM {items} AS i,
					   {categories} AS cat
				 WHERE item_id IN (" . DB::inInt($items_id) . ")
				   AND i.cat_id = cat.cat_id";
		if (!DB::execute($sql))
		{
			return [];
		}
		$items_infos = DB::fetchAll('item_id');

		// On réorganise les fichiers par album.
		$items_by_album = [];
		foreach ($items_infos as $id => &$i)
		{
			$items_by_album[$i['cat_id']][$id] = $i;
		}

		return $items_by_album;
	}

	/**
	 * Recalcule les statistiques des catégories parentes.
	 *
	 * @param array $update
	 *   Informations des catégories parentes à mettre à jour.
	 * @param array $i
	 *   Informations de l'élément.
	 *
	 * @return void
	 */
	private static function _parentsUpdateStats(array &$update, array &$i): void
	{
		// Note moyenne.
		if ($i['item_votes'] > 0)
		{
			$update['rating'] =
				(($update['rating'] * $update['votes'])
				+ ($i['item_rating'] * $i['item_votes']))
				/ ($update['votes'] + $i['item_votes']);
		}

		// Image ou vidéo.
		if (self::isImage($i['item_type']))
		{
			$update['images']++;
		}
		else
		{
			$update['videos']++;
		}

		// Autres informations.
		$update['comments'] += $i['item_comments'];
		$update['favorites'] += $i['item_favorites'];
		$update['hits'] += $i['item_hits'];
		$update['size'] += $i['item_filesize'];
		$update['votes'] += $i['item_votes'];
	}
}
?>