<?php
declare(strict_types = 1);

/**
 * Gestion des catégories.
 *
 * @license https://www.gnu.org/licenses/gpl-3.0.html
 * @link https://www.igalerie.org/
 */
class Category
{
	/**
	 * Longueur maximum des colonnes.
	 *
	 * @param array
	 */
	CONST COLUMNS_LENGTH_MAX =
	[
		'desc' => 65535,
		'name' => 255
	];



	/**
	 * Création d'une catégorie.
	 *
	 * @param string $cat_type
	 *   Type de la catégorie ('category' ou 'album').
	 * @param string $cat_name
	 *   Nom de la catégorie.
	 * @param string $cat_desc
	 *   Description de la catégorie.
	 * @param int $cat_status
	 *   État de la catégorie.
	 * @param int $parent_id
	 *   Identifiant de la catégorie parente.
	 * @param int $user_id
	 *   Identifiant de l'utilisateur propriétaire.
	 *
	 * @return int
	 *   Retourne l'identifiant de la catégorie créée,
	 *   1 si une catégorie avec le même titre existe déjà,
	 *   0 si aucune catégorie n'a pu être créée
	 *   (pour cause de paramètres incorrectes)
	 *   ou -1 en cas d'erreur.
	 */
	public static function create(string $cat_type, string $cat_name, string $cat_desc = '',
	int $cat_status = 0, int $parent_id = 1, int $user_id = 1): int
	{
		// Quelques vérifications basiques.
		if (!in_array($cat_status, [0, 1]) || $parent_id < 1 || $user_id < 1)
		{
			return 0;
		}

		// Vérification du titre et de la description.
		$cat_name = Utility::trimAll($cat_name);
		if (mb_strlen($cat_name) < 1
		 || mb_strlen($cat_name) > self::COLUMNS_LENGTH_MAX['name']
		 || mb_strlen($cat_desc) > self::COLUMNS_LENGTH_MAX['desc'])
		{
			return 0;
		}

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

		// Récupération des informations utiles de la catégorie parente.
		$sql = 'SELECT cat_id,
					   password_id,
					   cat_parents,
					   cat_path
				  FROM {categories} WHERE cat_id = ?';
		if (!DB::execute($sql, $parent_id))
		{
			return DB::rollback(-1);
		}
		$parent_infos = DB::fetchRow();
		if (!isset($parent_infos['cat_id']))
		{
			return DB::rollback(0);
		}

		// On vérifie qu'une catégorie avec le même titre
		// n'existe pas déjà dans la catégorie parente.
		$sql = 'SELECT 1 FROM {categories} WHERE LOWER(cat_name) = ? AND parent_id = ?';
		if (!DB::execute($sql, [mb_strtolower($cat_name), $parent_id]))
		{
			return DB::rollback(-1);
		}
		if (DB::fetchVal())
		{
			return 1;
		}

		// On génère le nom de répertoire.
		$parent_path = CONF_ALBUMS_PATH . '/' . $parent_infos['cat_path'];
		$cat_dirname = App::getValidDirname($cat_name);
		$cat_dirname = File::getSecureFilename($cat_dirname, $parent_path);
		if (strlen($cat_dirname) < 1)
		{
			return DB::rollback(-1);
		}

		// On ajoute la nouvelle catégorie à la base de données.
		$sql = 'INSERT INTO {categories} (
			user_id, thumb_id, password_id, parent_id, cat_parents, cat_path,
			cat_name, cat_url, cat_desc, cat_crtdt, cat_filemtime, cat_status
			) VALUES (
			:user_id, :thumb_id, :password_id, :parent_id, :cat_parents, :cat_path,
			:cat_name, :cat_url, :cat_desc, :cat_crtdt, :cat_filemtime, :cat_status
			)';
		$cat_parents = $parent_infos['cat_parents'];
		if ($parent_id > 1)
		{
			$cat_parents .= $parent_id . Parents::SEPARATOR;
		}
		$cat_path = $parent_id == 1
			? $cat_dirname
			: $parent_infos['cat_path'] . '/' . $cat_dirname;
		$datetime = date('Y-m-d H:i:s');
		$params =
		[
			'user_id' => $user_id,
			'thumb_id' => -1,
			'password_id' => $parent_infos['password_id'],
			'parent_id' => $parent_id,
			'cat_parents' => $cat_parents,
			'cat_path' => $cat_path,
			'cat_name' => $cat_name,
			'cat_url' => App::getURLName($cat_name),
			'cat_desc' => Utility::trimAll($cat_desc) === ''
				? NULL
				: $cat_desc,
			'cat_crtdt' => $datetime,
			'cat_filemtime' => $cat_type == 'album'
				? $datetime
				: NULL,
			'cat_status' => $cat_status
		];
		if (!DB::execute($sql, $params))
		{
			return DB::rollback(-1);
		}

		// Mise à jour de la colonne "cat_position".		
		$sql = 'SELECT cat_id FROM {categories} WHERE cat_path = ?';
		if (!DB::execute($sql, $cat_path))
		{
			return DB::rollback(-1);
		}
		$cat_id = DB::fetchVal();
		$sql = 'UPDATE {categories} SET cat_position = ? WHERE cat_id = ?';
		if (!DB::execute($sql, [$cat_id, $cat_id]))
		{
			return DB::rollback(-1);
		}

		// Si la catégorie est activée, on active aussi les catégories parentes.
		$parents_id = self::getParentsIdArray($cat_parents);
		if ($cat_status)
		{
			$sql = 'UPDATE {categories}
					   SET cat_status = "1"
					 WHERE cat_id IN (' . DB::inInt($parents_id) . ')';
			if (!DB::execute($sql))
			{
				return DB::rollback(-1);
			}
		}

		// On met à jour le nombre de sous-catégories des catégories parentes.
		if (!Parents::updateSubCats($parents_id))
		{
			return DB::rollback(-1);
		}

		// On update les catégories parentes du nombre d'albums.
		if ($cat_type == 'album')
		{
			$stats =
			[
				'albums' => 1,
				'comments' => 0,
				'favorites' => 0,
				'hits' => 0,
				'images' => 0,
				'rating' => 0,
				'size' => 0,
				'videos' => 0,
				'votes' => 0
			];
			if ($cat_status)
			{
				$cat_update = ['a' => $stats];
				$a_sign = '+';
				$d_sign = '';
			}
			else
			{
				$cat_update = ['d' => $stats];
				$a_sign = '';
				$d_sign = '+';
			}
			if (!Parents::updateStats($cat_update, $a_sign, $d_sign, $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);
		}

		$realpath = $parent_path . '/' . $cat_dirname;

		// On tente de créer le répertoire.
		if (!File::mkdir($realpath))
		{
			return DB::rollback(-1);
		}

		// Exécution de la transaction.
		if (!DB::commitTransaction())
		{
			// On supprime le répertoire en cas d'échec de la transaction.
			if (is_dir($realpath))
			{
				File::rmdir($realpath);
			}
			return DB::rollback(-1);
		}

		return (int) $cat_id;
	}

	/**
	 * Supprime des catégories, avec toutes leurs sous-catégories,
	 * albums, images, vidéos, tags, votes et commentaires qui leurs sont liés.
	 *
	 * @param array $categories_id
	 *   Identifiant des catégories.
	 *
	 * @return int
	 *   Retourne le nombre de catégories affectées
	 *   ou -1 en cas d'erreur.
	 */
	public static function delete(array $categories_id): int
	{
		if (!$categories_id)
		{
			return 0;
		}

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

		// Récupération des informations des catégories.
		if (!($categories_by_parent = self::_getCategoriesByParent($categories_id)))
		{
			return DB::rollback(0);
		}

		// On traite les catégories catégorie parente par catégorie parente.
		$update_parents_id = [];
		$categories_deleted = 0;
		foreach ($categories_by_parent as &$categories_infos)
		{
			// Suppression des catégories.
			$sql = 'DELETE
					  FROM {categories}
					 WHERE cat_id IN (' . DB::inInt(array_keys($categories_infos)) . ')';
			if (!DB::execute($sql))
			{
				return DB::rollback(-1);
			}
			$categories_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 ($categories_infos as &$i)
			{
				// Nombre d'albums.
				if ($i['cat_filemtime'] === NULL)
				{
					$cat_update['a']['albums'] += $i['cat_a_albums'];
					$cat_update['d']['albums'] += $i['cat_d_albums'];
				}
				else
				{
					$status = ($i['cat_status']) ? 'a' : 'd';
					$cat_update[$status]['albums']++;
				}

				// Autres stats.
				self::_parentsUpdateStats($cat_update['a'], $i, 'a');
				self::_parentsUpdateStats($cat_update['d'], $i, 'd');
			}

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

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

		if ($categories_deleted < 1)
		{
			return DB::rollback(0);
		}

		// 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 catégories sur le disque.
		foreach ($categories_by_parent as &$categories)
		{
			foreach ($categories as &$i)
			{
				$dir = CONF_ALBUMS_PATH . '/' . $i['cat_path'];
				if (!file_exists($dir))
				{
					continue;
				}
				if (!File::rmdir($dir))
				{
					return -1;
				}
			}
		}

		return $categories_deleted;
	}

	/**
	 * Édite les informations de plusieurs catégories.
	 *
	 * @param array $categories_update
	 *   Informations de mise à jour.
	 *
	 * @return int
	 *   Retourne le nombre de catégories affectées
	 *   ou -1 en cas d'erreur.
	 */
	public static function edit(array &$categories_update): int
	{
		// Début de la transaction.
		if (!DB::beginTransaction())
		{
			return -1;
		}

		// Récupération des informations des catégories.
		$sql = 'SELECT *
				 FROM {categories}
				WHERE cat_id IN (' . DB::inInt(array_keys($categories_update)) . ')';
		if (!DB::execute($sql))
		{
			return DB::rollback(-1);
		}
		if (!$categories_infos = DB::fetchAll('cat_id'))
		{
			return DB::rollback(0);
		}
		$categories_id = array_keys($categories_infos);

		// Mise à jour des informations des catégories.
		$params = [];
		$rename = [];
		$order_by_params_album = self::getOrderByParams()['album'];
		$order_by_params_category = self::getOrderByParams()['category'];
		foreach ($categories_update as $id => &$update)
		{
			if (!array_key_exists($id, $categories_infos))
			{
				continue;
			}

			// On regroupe les critères de tri séparés.
			$album = $categories_infos[$id]['cat_filemtime'] !== NULL;
			$order_by_params = $categories_infos[$id]['cat_filemtime'] === NULL
				? $order_by_params_category
				: $order_by_params_album;
			$cat_orderby = [];
			foreach ([1, 2, 3] as $n)
			{
				if (array_key_exists('sortby_' . $n, $update)
				 && array_key_exists('orderby_' . $n, $update)
				 && isset($order_by_params[$update['sortby_' . $n]])
				 && $update['sortby_' . $n] != 'none'
				 && in_array($update['orderby_' . $n], ['ASC', 'DESC']))
				{
					$cat_orderby[] = $update['sortby_' . $n] . ' ' . $update['orderby_' . $n];
				}
			}
			if (count($cat_orderby))
			{
				$update['cat_orderby'] = implode(', ', $cat_orderby);
			}

			// Colonnes modifiables.
			$cols =
			[
				'password_id',
				'cat_name',
				'cat_url',
				'cat_path',
				'cat_desc',
				'cat_orderby',
				'cat_commentable',
				'cat_creatable',
				'cat_downloadable',
				'cat_uploadable',
				'cat_votable'
			];
			$p = [];
			foreach ($cols as &$name)
			{
				$p[$name] = $categories_infos[$id][$name];
			}
			$p_temp = $p;

			foreach ($update as $col => &$value)
			{
				if (!array_key_exists($col, $p))
				{
					continue;
				}

				switch ($col)
				{
					// Titre et URL.
					case 'cat_name' :
					case 'cat_url' :
						$val = Utility::trimAll((string) $value);
						$val = $col == 'cat_url' ? App::getURLName($val) : $val;
						$val = mb_substr($val, 0, 255);
						if (mb_strlen($val) > 0)
						{
							$p[$col] = $val;
						}
						break;

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

					// Nom de répertoire.
					case 'cat_path' :
						$r = self::_changeDirname($categories_infos[$id], $value);
						if ($r === FALSE)
						{
							return DB::rollback(-1);
						}
						$p[$col] = $r[0];
						if (count($r) > 1)
						{
							$rename[$r[1]] = $r[2];
						}
						break;

					// Permissions.
					case 'cat_commentable' :
					case 'cat_creatable' :
					case 'cat_downloadable' :
					case 'cat_uploadable' :
					case 'cat_votable' :

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

					// Critères de tri.
					case 'cat_orderby' :
						if (preg_match('`^([\sa-z_]{3,30}, ){1,3}$`i', $value . ', '))
						{
							$p[$col] = strstr($value, 'default') ? NULL : $value;
						}
						break;

					// Mot de passe.
					case 'password_id' :
						$r = self::_changePassword($categories_infos[$id], $value);
						if ($r === FALSE)
						{
							return DB::rollback(-1);
						}
						if ($r['change'])
						{
							$p_temp[$col] = 0;
							$p[$col] = $r['id'];
						}
						if ($r['id'] !== NULL)
						{
							$value = '**********';
						}
						break;
				}
			}

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

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

		// Colonnes à mettre à jour.
		$columns = '';
		foreach (array_keys($params[0]) as &$col)
		{
			$columns .= $col . ' = :' . $col . ', ';
		}
		$columns = substr($columns, 0, -2);

		// On met à jour les catégories.
		if (!DB::execute("UPDATE {categories} SET $columns WHERE cat_id = :cat_id", $params))
		{
			return DB::rollback(-1);
		}

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

		// On renomme les catégories.
		foreach ($rename as $old_path => &$new_path)
		{
			if (file_exists($old_path))
			{
				File::rename($old_path, $new_path);
			}
		}

		return count($params);
	}

	/**
	 * Retourne des informations utiles de toutes les catégories de la galerie.
	 * Cette méthode permet également de récupérer la fonction $makelist
	 * permettant de générer facilement une liste HTML des catégories.
	 *
	 * @param Closure $makelist
	 * @param Closure $get_url
	 *   Fonction permettant de créer un lien.
	 * @param int $category_id
	 *   Identifiant de la catégorie courante.
	 * @param string $perms
	 *   Code SQL contenant les restrictions d'accès aux catégories.
	 * @param string $password
	 * @param string $order_by
	 *
	 * @return bool|array
	 *   Retourne FALSE en cas d'erreur.
	 */
	public static function getList(&$makelist, Closure $get_url, int $category_id,
	string $perms = '1=1', string $password = '1 AS password_auth',
	string $order_by = 'cat_name ASC')
	{
		// Récupération des catégories.
		if (!$categories = self::_getCategories($password, $perms, $order_by))
		{
			return FALSE;
		}

		// Construction de la liste.
		$makelist = function(array $categories, int $id = 0, int $level = 0,
		bool $only_cat = FALSE) use (&$makelist, $get_url, $category_id): array
		{
			$sublist = [];
			foreach ($categories as &$i)
			{
				if ($i['parent_id'] != $id)
				{
					continue;
				}

				if ($only_cat && $i['cat_type'] == 'album')
				{
					continue;
				}

				// Ajout de la catégorie à la liste.
				$password = $i['p_cat_id'] == $i['cat_id'];
				$unlocked = $i['password_id'] && $i['password_auth'];
				$count = $i['cat_a_images'] + $i['cat_a_videos'];
				$count += App::$scriptName == 'admin'
					? $i['cat_d_images'] + $i['cat_d_videos']
					: 0;
				$sublist[] = ['node' => 'element_start'];
				$sublist[] =
				[
					'node' => 'content',
					'count' => $i['password_auth'] ? $i['count'] ?? $count : 0,
					'current' => (int) ($category_id && $i['cat_id'] == $category_id),
					'level' => $level,
					'id' => $i['cat_id'],
					'title' => $i['cat_id'] > 1 ? $i['cat_name'] : __('galerie'),
					'type' => $i['cat_type'],

					// Lien vers la catégorie.
					'link' => $get_url($i['cat_type'] . '/' . $i['cat_id']),

					// Lien vers la catégorie avec nom d'URL.
					'link_urlname' => $get_url($i['cat_type'] . '/' . $i['cat_id'] . '-'
						. ($i['cat_id'] > 1 ? $i['cat_url'] : __('galerie'))),

					// Lien vers la page de connexion à la catégorie.
					'link_password' => $get_url(
						'password/' . $i['cat_type'] . '/' . $i['cat_id']
					),

					// Identifiants des catégories parentes.
					'parents' => substr($i['cat_parents'], 0, -1),

					// Indique si un mot de passe a été défini sur la catégorie.
					'password' => $password,

					// Indique s'il y a des sous-catégories accessibles.
					'subcats' => (int) ($i['cat_id'] > 1 &&
						((!$only_cat && App::$scriptName == 'gallery'
							&& $i['cat_type'] == 'category')
						    || (!$only_cat && App::$scriptName == 'admin'
							&& ($i['cat_subcats'] + $i['cat_subalbs']) > 0)
							|| ($only_cat && $i['cat_subcats'] > 0))
						&& (!$password || ($password && $unlocked))),

					// Indique si l'utilisateur n'a pas l'autorisation d'accèder
					// à une catégorie protégée par mot de passe.
					'locked' => (bool) !$i['password_auth'],

					// Indique si l'utilisateur a l'autorisation d'accèder
					// à une catégorie protégée par mot de passe.
					'unlocked' => $unlocked
				];
				if ($i['cat_type'] == 'category')
				{
					if ($r = $makelist($categories, (int) $i['cat_id'], $level + 1, $only_cat))
					{
						$sublist = array_merge($sublist, $r);
					}
				}
				$sublist[] = ['node' => 'element_end'];

				// Si c'est un album, on le supprime du tableau des catégories.
				if ($i['cat_type'] == 'album')
				{
					unset($categories[$i['cat_id']]);
				}
			}
			$list = [];
			if ($sublist)
			{
				$list[] = ['node' => 'list_start'];
				$list = array_merge($list, $sublist);
				$list[] = ['node' => 'list_end'];
				
			}
			return $list;
		};

		return $categories;
	}

	/**
	 * Retourne des informations utiles de toutes les catégories de la galerie.
	 * Cette méthode permet également de récupérer la fonction $makelist
	 * permettant de générer facilement une liste des catégories au format JSON.
	 *
	 * @param Closure $makelist
	 * @param int $category_id
	 *   Identifiant de la catégorie courante.
	 * @param string $perms
	 *   Code SQL contenant les restrictions d'accès aux catégories.
	 * @param string $password
	 * @param string $order_by
	 *
	 * @return bool|array
	 *   Retourne FALSE en cas d'erreur.
	 */
	public static function getListJSON(&$makelist, int $category_id,
	string $perms = '1=1', string $password = '1 AS password_auth',
	string $order_by = 'cat_name ASC')
	{
		$categories = self::_getCategories($password, $perms, $order_by);
		if ($categories === FALSE)
		{
			return FALSE;
		}

		// Construction de la liste.
		$makelist = function(array $categories, int $id = 0, int $level = 0,
		bool $only_cat = FALSE) use (&$makelist, $category_id): array
		{
			$sublist = [];

			foreach ($categories as &$i)
			{
				if ($i['parent_id'] != $id)
				{
					continue;
				}

				if ($only_cat && $i['cat_type'] == 'album')
				{
					continue;
				}

				$password = $i['p_cat_id'] == $i['cat_id'];
				$unlocked = $i['password_id'] && $i['password_auth'];

				$class = $level > 1 ? '' : 'v';

				// Indique s'il y a des sous-catégories accessibles.
				if ($i['cat_id'] > 1 &&
					((!$only_cat && App::$scriptName == 'gallery'
						&& $i['cat_type'] == 'category')
						|| (!$only_cat && App::$scriptName == 'admin'
						&& ($i['cat_subcats'] + $i['cat_subalbs']) > 0)
						|| ($only_cat && $i['cat_subcats'] > 0))
					&& (!$password || ($password && $unlocked)))
				{
					$class .= 's';
				}

				// Current.
				if ($category_id && $i['cat_id'] == $category_id)
				{
					$class .= 'c';
				}

				// Locked.
				if ((bool) !$i['password_auth'])
				{
					$class .= 'l';
				}

				// Unlocked.
				if ($unlocked)
				{
					$class .= 'u';
				}

				$data =
				[
					$class . ':' . $level . '{' . $i['cat_id'] . '}'
						. substr($i['cat_parents'], 0, -1),
					$i['cat_id'] > 1 ? $i['cat_name'] : __('galerie'),
					$i['cat_id'] > 1 ? $i['cat_url'] : __('galerie')
				];
				if (Config::$params['browse_items_count'])
				{
					$count = $i['cat_a_images'] + $i['cat_a_videos'];
					$count += App::$scriptName == 'admin'
						? $i['cat_d_images'] + $i['cat_d_videos']
						: 0;
					$data[] = L10N::formatNumber(
						$i['password_auth'] ? $i['count'] ?? $count : 0
					);
				}
				$sublist[] = $data;

				if ($i['cat_type'] == 'category')
				{
					if ($r = $makelist($categories,
					(int) $i['cat_id'], $level + 1, $only_cat))
					{
						$sublist = array_merge($sublist, $r);
					}
				}

				// Si c'est un album, on le supprime du tableau des catégories.
				if ($i['cat_type'] == 'album')
				{
					unset($categories[$i['cat_id']]);
				}
			}
			$list = [];
			if ($sublist)
			{
				$list = array_merge($list, $sublist);
			}
			return $list;
		};

		return $categories;
	}

	/**
	 * Retourne la liste des critères de tri des objets d'une catégorie.
	 *
	 * @return array
	 */
	public static function getOrderByParams(): array
	{
		static $order, $category, $album;

		if (!$order)
		{
			$order =
			[
				'ASC' => __('Croissant'),
				'DESC' => __('Décroissant')
			];
			$category =
			[
				'default' => '*' . __('Par défaut'),
				'none' => '*' . __('Aucun'),
				'cat_id' => __('Identifiant'),
				'cat_position' => __('Tri manuel'),
				'cat_name' => __('Titre (alphabétique)'),
				'cat_name_num' => __('Titre (numérique)'),
				'cat_path' => __('Nom de répertoire (alphabétique)'),
				'cat_path_num' => __('Nom de répertoire (numérique)'),
				'cat_crtdt' => __('Date de création'),
				'cat_lastpubdt' => __('Date de mise à jour'),
				'cat_a_size' => __('Poids'),
				'cat_a_items' => __('Nombre de fichiers')
			];
			$album =
			[
				'default' => '*' . __('Par défaut'),
				'none' => '*' . __('Aucun'),
				'item_id' => __('Identifiant'),
				'item_position' => __('Tri manuel'),
				'item_name' => __('Titre (alphabétique)'),
				'item_name_num' => __('Titre (numérique)'),
				'item_path' => __('Nom de fichier (alphabétique)'),
				'item_path_num' => __('Nom de fichier (numérique)'),
				'item_size' => __('Dimensions'),
				'item_filesize' => __('Poids'),
				'item_hits' => __('Nombre de vues'),
				'item_comments' => __('Nombre de commentaires'),
				'item_votes' => __('Nombre de votes'),
				'item_rating' => __('Note moyenne'),
				'item_favorites' => __('Nombre de favoris'),
				'item_adddt' => __('Date d\'ajout'),
				'item_pubdt' => __('Date de publication'),
				'item_crtdt' => __('Date de création')
			];
		}

		return ['order' => $order, 'category' => $category, 'album' => $album];
	}

	/**
	 * Retourne l'identifiant des catégories parentes
	 * d'une catégorie sous forme de tableau.
	 *
	 * @param string $parents_id
	 *   Identifiants des catégories parentes dans le même
	 *   format que celui utilisé en base de données avec
	 *   la colonne 'cat_parents' de la table 'categories'.
	 * @param mixed $cat_id
	 *   Identifiant de la catégorie.
	 *
	 * @return array
	 */
	public static function getParentsIdArray(string $parents_id, $cat_id = 0): array
	{
		$parents_id = explode(Parents::SEPARATOR, substr($parents_id, 0, -1));
		if ($cat_id)
		{
			$parents_id[] = (int) $cat_id;
		}
		return array_map('intval', $parents_id);
	}

	/**
	 * Retourne les informations d'un album dans lequel
	 * un utilisateur va ajouter des fichiers.
	 * Si l'utilisateur n'a pas les permissions pour accéder
	 * à l'album, alors la chaîne retournée sera vide.
	 *
	 * @param int $cat_id
	 *   Identifiant de l'album.
	 * @param bool $from_admin
	 *   L'ajout de fichiers se fait-il depuis l'interface d'administration ?
	 *
	 * @return array|bool
	 *   Retourne un tableau des informations de l'album,
	 *   un tableau vide si l'album n'existe pas
	 *   ou que l'utilisateur n'a pas les permissions d'y accèder,
	 *   ou FALSE en cas d'erreur.
	 */
	public static function getInfosUpload(int $cat_id, bool $from_admin = FALSE)
	{
		$from_admin = Auth::$isAdmin ? $from_admin : FALSE;

		$sql = '';
		DB::params(['cat_id' => $cat_id]);

		// Depuis l'administration.
		if ($from_admin)
		{
			$sql = 'SELECT cat_path
					  FROM {categories}
					 WHERE cat_id = :cat_id
					   AND cat_filemtime IS NOT NULL';
		}

		// Depuis la galerie.
		else
		{
			// Récupération des informations de l'album.
			$sql = 'SELECT *
					  FROM {categories} AS cat
					 WHERE cat_id = :cat_id
					   AND %s
					   AND %s
					   AND cat_status = "1"
					   AND cat_filemtime IS NOT NULL';
			$sql = sprintf($sql, SQL::catPerms(), SQL::catPassword());

			// L'utilisateur doit-il être propriétaire de l'album ?
			if (Auth::$groupPerms['upload_owner'])
			{
				$sql .= ' AND user_id = :user_id';
				DB::params(['user_id' => Auth::$id]);
			}
		}
		if (!DB::execute($sql))
		{
			return FALSE;
		}
		if (!$cat_infos = DB::fetchRow())
		{
			return [];
		}

		// L'ajout de fichiers est-il autorisé pour cet album ?
		if (!$from_admin)
		{
			$cat_infos = [$cat_infos];
			Parents::settings($cat_infos);
			$cat_infos = $cat_infos[0];
			if (!$cat_infos['parent_uploadable'] || !$cat_infos['cat_uploadable'])
			{
				return [];
			}
		}

		return $cat_infos;
	}

	/**
	 * Retourne le titre d'une catégorie à partir de son nom de répertoire.
	 *
	 * @param string $dirname
	 *
	 * @return string
	 */
	public static function getTitle(string $dirname): string
	{
		$title = str_replace('_', ' ', $dirname);
		$title = Utility::trimAll($title);
		$title = Utility::UTF8($title);

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

		return $title;
	}

	/**
	 * Déplace des catégories.
	 *
	 * @param array $categories_id
	 *   Identifiant des catégories.
	 * @param int $dest_id
	 *   Identifiant de la catégorie destination.
	 *
	 * @return int
	 *   Retourne le nombre de catégories affectées
	 *   ou -1 en cas d'erreur.
	 */
	public static function move(array $categories_id, int $dest_id): int
	{
		if (!$categories_id)
		{
			return 0;
		}

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

		// Récupération des informations de la catégorie destination.
		$sql = 'SELECT password_id,
					   cat_path,
					   cat_parents
				  FROM {categories}
				 WHERE cat_id = ?
				   AND cat_filemtime IS 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'];
		$dest_path = $dest_path == '.' ? '' : $dest_path . '/';
		$dest_parents_id = self::getParentsIdArray($dest_infos['cat_parents']);
		if ($dest_id > 1)
		{
			$dest_parents_id[] = $dest_id;
		}
		$cat_parents = implode(Parents::SEPARATOR, $dest_parents_id) . Parents::SEPARATOR;
		$dest_password = $dest_infos['password_id'];

		// Récupération des informations des catégories.
		if (!($categories_by_parent = self::_getCategoriesByParent($categories_id)))
		{
			return DB::rollback(0);
		}

		// On traite les catégories catégorie parente par catégorie parente.
		$params = [];
		$update_parents_id = [];
		foreach ($categories_by_parent as &$categories_infos)
		{
			$status = 0;
			$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];
			$categories_moved = 0;
			foreach ($categories_infos as &$i)
			{
				// On vérifie que la destination n'est pas identique
				// à la catégorie parente ou qu'elle n'est pas un descendant
				// de l'une des catégories.
				if (preg_match('`^' . $i['cat_path'] . '/.*`', $dest_path)
				 || $i['parent_id'] == $dest_id)
				{
					continue;
				}
				$categories_moved++;

				// On transmet le mot de passe de la catégorie destination
				// à la catégorie à déplacer, sauf si cette dernière possède
				// un mot de passe propre, c'est à dire qui a été placé sur
				// cette catégorie et non sur une catégorie parente.
				$password_id = $i['cat_id'] == $i['p_cat_id']
					? $i['password_id']
					: $dest_password;

				// On renomme le répertoire de la catégorie si une catégorie
				// de même nom existe déjà dans la catégorie cible.
				$new_path = $dest_path . basename($i['cat_path']);
				$new_path = File::getSecureFilename($new_path, CONF_ALBUMS_PATH . '/');
				$rename[] = [$i['cat_path'], $new_path];

				if (!self::_updateChilds($i, $new_path, $dest_parents_id, $password_id))
				{
					return DB::rollback(-1);
				}

				// On calcule les valeurs des colonnes
				// des catégories parentes à mettre à jour.
				foreach (['a', 'd'] as $s)
				{
					// Nombre d'albums.
					$cat_update[$s]['albums'] += ($i['cat_filemtime'] !== NULL)
						? (($i['cat_status']) ? ($s == 'a' ? '1' : '0') : ($s == 'a' ? '0' : '1'))
						: $i['cat_' . $s . '_albums'];

					// Autres stats.
					self::_parentsUpdateStats($cat_update[$s], $i, $s);
				}

				$params[] =
				[
					'parent_id' => $dest_id,
					'cat_id' => $i['cat_id'],
					'cat_parents' => $cat_parents
				];

				// Force l'état des catégories parentes à 1
				// si la catégorie déplacée est à 1.
				if ($i['cat_status'])
				{
					$status = 1;
				}
			}
			if ($categories_moved < 1)
			{
				continue;
			}

			// Mise à jour des informations des catégories parentes.
			$source_parents_id = self::getParentsIdArray(
				current($categories_infos)['cat_parents']
			);
			$update_parents_id += array_flip($source_parents_id);
			if (!Parents::updateStats($cat_update, '-', '-', $source_parents_id)
			 || !Parents::updateStats($cat_update, '+', '+', $dest_parents_id)
			 || !Parents::updateInfos($source_parents_id)
			 || !Parents::updateInfos($dest_parents_id, $status))
			{
				return DB::rollback(-1);
			}
		}

		if (count($params) < 1)
		{
			return DB::rollback(0);
		}

		// On change les informations sur les catégories parentes.
		$sql = 'UPDATE {categories}
				   SET parent_id = :parent_id,
					   cat_parents = :cat_parents
				 WHERE cat_id = :cat_id';
		if (!DB::execute($sql, $params))
		{
			return DB::rollback(-1);
		}
		$categories_moved = DB::rowCount();

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

	/**
	 * Change le propriétaire de plusieurs catégories.
	 *
	 * @param array $categories_id
	 *   Identifiant des catégories.
	 * @param int $user_id
	 *   Identifiant du nouveau propriétaire.
	 *
	 * @return int
	 *   Retourne le nombre de catégories affectées
	 *   ou -1 en cas d'erreur.
	 */
	public static function owner(array $categories_id, int $user_id): int
	{
		if (!$categories_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;
		}

		// On ne modifie les catégories que si elles ont un propriétaire différent.
		$sql = 'SELECT cat_id
				  FROM {categories}
				 WHERE cat_id IN (' . DB::inInt($categories_id) . ')
				   AND user_id != ?';
		if (!DB::execute($sql, $user_id))
		{
			return -1;
		}
		if (!$categories_id = DB::fetchAll('cat_id', 'cat_id'))
		{
			return 0;
		}

		// On change le propriétaire des catégories.
		foreach ($categories_id as &$id)
		{
			$params[] =
			[
				'cat_id' => $id,
				'cat_parents' => '%' . Parents::SEPARATOR . $id . Parents::SEPARATOR . '%',
				'user_id' => $user_id
			];
		}
		$sql = 'UPDATE {categories}
				   SET user_id = :user_id
				 WHERE (cat_id = :cat_id
					OR parent_id = :cat_id
					OR cat_parents LIKE :cat_parents)
				   AND user_id != :user_id';
		if (!DB::execute($sql, $params))
		{
			return -1;
		}

		return DB::rowCount();
	}

	/**
	 * Réduit la liste des catégories $list pour ne conserver que
	 * les catégories de $categories ainsi que leurs catégories parentes.
	 *
	 * @param array $list
	 *   Liste des catégories.
	 * @param array $categories
	 *   Identifiants des catégories à conserver.
	 * @param bool $count
	 *   Modifier le nombre d'éléments de la liste ?
	 *
	 * @return void
	 */
	public static function reduceList(array &$list, array &$categories, bool $count = TRUE): void
	{
		$list_ids = [1];
		foreach ($list as $id => &$i)
		{
			if ($i['cat_filemtime'] !== NULL)
			{
				if (array_key_exists($id, $categories))
				{
					foreach (explode(Parents::SEPARATOR, $i['cat_parents']) as $pid)
					{
						if (!in_array($pid, $list_ids))
						{
							$list_ids[] = $pid;
						}
					}
				}
				else
				{
					unset($list[$id]);
				}
			}
		}
		foreach ($list as $id => &$i)
		{
			if ($i['cat_filemtime'] === NULL && !in_array($id, $list_ids))
			{
				unset($list[$id]);
				continue;
			}
			if ($count && isset($categories[$i['cat_id']]))
			{
				$i['count'] = (int) $categories[$i['cat_id']];
				$parents = explode(Parents::SEPARATOR, substr($i['cat_parents'], 0, -1));
				foreach ($parents as &$id)
				{
					if (isset($list[$id]['count']))
					{
						$list[$id]['count'] += $i['count'];
					}
					else
					{
						$list[$id]['count'] = $i['count'];
					}
				}
			}
		}
	}

	/**
	 * Remet à zéro le nombre de vues de plusieurs catégories.
	 *
	 * @param array $categories_id
	 *   Identifiant des catégories.
	 *
	 * @return int
	 *   Retourne le nombre de catégories affectées
	 *   ou -1 en cas d'erreur.
	 */
	public static function resetHits(array $categories_id): int
	{
		if (!$categories_id)
		{
			return 0;
		}

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

		// Récupération des informations des catégories.
		if (!($categories_by_parent = self::_getCategoriesByParent($categories_id)))
		{
			return DB::rollback(0);
		}

		// On traite les catégories catégorie parente par catégorie parente.
		$nb_categories = 0;
		$params_childs = [];
		$params_items = [];
		$update_cats = [];
		foreach ($categories_by_parent as &$categories_infos)
		{
			$cat_a_hits = 0;
			$cat_d_hits = 0;
			foreach ($categories_infos as &$i)
			{
				// Si la catégorie ne contient aucun fichier ou aucune vue,
				// inutile de faire la mettre à jour.
				if (($i['cat_a_images'] + $i['cat_d_images']
				   + $i['cat_a_videos'] + $i['cat_d_videos']) == 0
				|| ($i['cat_a_hits'] + $i['cat_d_hits']) == 0)
				{
					continue;
				}

				$path_childs = DB::likeEscape($i['cat_path']) . '/%';

				// Nombre de vues.
				$cat_a_hits += $i['cat_a_hits'];
				$cat_d_hits += $i['cat_d_hits'];

				// Mise à jour des fichiers.
				$params_items[] = $path_childs;

				// Mise à jour de la catégorie.
				$update_cats[] = $i['cat_id'];
				$nb_categories++;

				// Mise à jour des catégories enfants, si ce n'est pas un album.
				if ($i['cat_filemtime'] === NULL)
				{
					$params_childs[] = $path_childs;
				}
			}

			// Mise à jour des catégories parentes.
			$parents_ids = self::getParentsIdArray(current($categories_infos)['cat_parents']);
			$sql = "UPDATE {categories}
					   SET cat_a_hits = cat_a_hits - $cat_a_hits,
						   cat_d_hits = cat_d_hits - $cat_d_hits
					 WHERE cat_id IN (" . DB::inInt($parents_ids) . ")";
			if (!DB::execute($sql))
			{
				return DB::rollback(-1);
			}
		}

		// Mise à jour des catégories.
		if ($update_cats)
		{
			$sql = 'UPDATE {categories}
			           SET cat_a_hits = 0,
					       cat_d_hits = 0
					 WHERE cat_id IN (' . DB::inInt($update_cats) . ')';
			if (!DB::execute($sql))
			{
				return DB::rollback(-1);
			}
		}

		// Mise à jour des sous-catégories.
		if ($params_childs)
		{
			$sql_where = str_repeat('cat_path LIKE ? OR ', count($params_childs));
			$sql_where = substr($sql_where, 0, -4);
			$sql = "UPDATE {categories}
					   SET cat_a_hits = 0,
						   cat_d_hits = 0
					 WHERE $sql_where";
			if (!DB::execute($sql, $params_childs))
			{
				return DB::rollback(-1);
			}
		}

		// Mise à jour des fichiers.
		if ($params_items)
		{
			$sql_where = str_repeat('item_path LIKE ? OR ', count($params_items));
			$sql_where = substr($sql_where, 0, -4);
			$sql = "UPDATE {items}
					   SET item_hits = 0
					 WHERE $sql_where";
			if (!DB::execute($sql, $params_items))
			{
				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 $nb_categories;
	}

	/**
	 * Change l'état (activé, désactivé) de plusieurs catégories.
	 *
	 * @param array $categories_id
	 *   Identifiant des catégories.
	 * @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 catégories affectées
	 *   ou -1 en cas d'erreur.
	 */
	public static function status(array $categories_id, int $status,
	bool $reset_item_pubdt = FALSE): int
	{
		if (!$categories_id || $status < 0 || $status > 1)
		{
			return 0;
		}

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

		// Récupération des informations des catégories.
		if (!($categories_by_parent = self::_getCategoriesByParent($categories_id)))
		{
			return DB::rollback(0);
		}

		// On traite les catégories par catégorie parente.
		$update_parents_id = [];
		$nb_categories = 0;
		$s = $status ? 'd' : 'a';
		$albums_id_notify = [];
		foreach ($categories_by_parent as &$categories_infos)
		{
			// On calcule les valeurs des colonnes
			// des catégories parentes à mettre à jour.
			$sql_items = '';
			$sql_categories = '';
			$params = [];
			$cat_update =
			[
				'albums' => 0,
				'comments' => 0,
				'favorites' => 0,
				'hits' => 0,
				'images' => 0,
				'rating' => 0,
				'size' => 0,
				'videos' => 0,
				'votes' => 0
			];
			foreach ($categories_infos as &$i)
			{
				// Si l'état de la catégorie est le même, on l'ignore.
				if ($i['cat_status'] == $status)
				{
					continue;
				}

				$nb_categories++;
				$cat_path = DB::likeEscape($i['cat_path']) . '/%';

				// Chemins des enfants à mettre à jour.
				$sql_items .= 'cat_id = ? OR item_path LIKE ? OR ';
				$sql_categories .= 'cat_id = ? OR cat_path LIKE ? OR ';

				// Paramètres des requêtes préparées.
				$params[] = $i['cat_id'];
				$params[] = $cat_path;

				// Nombre d'albums.
				if ($i['cat_filemtime'] === NULL)
				{
					$cat_update['albums'] += $i['cat_' . $s . '_albums'];
				}
				else
				{
					$cat_update['albums']++;
				}

				// Autres stats.
				self::_parentsUpdateStats($cat_update, $i, $s);
			}

			if (!$params)
			{
				continue;
			}

			// On met à jour les 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';

				// Identifiant des albums pour la notification par courriel.
				$sql = 'SELECT cat_id
						  FROM {items}
						 WHERE (' . substr($sql_items, 0, -4) . ')
						   AND item_pubdt IS NULL
					  GROUP BY cat_id';
				if (!DB::execute($sql, $params))
				{
					return DB::rollback(-1);
				}
				$albums_id_notify = array_merge($albums_id_notify, DB::fetchCol('cat_id'));
			}
			else
			{
				$sql_update = ['item_status = CASE
						   WHEN item_status = "-1" THEN "-1"
						   ELSE "0" END'];
			}
			$sql = 'UPDATE {items}
					   SET ' . implode(', ', $sql_update) . '
					 WHERE ' . substr($sql_items, 0, -4);
			if (!DB::execute($sql, $params))
			{
				return DB::rollback(-1);
			}

			// On récupère l'identifiant des catégories enfants.
			$sql_categories = substr($sql_categories, 0, -4);
			$sql = "SELECT cat_id,
						   parent_id,
						   cat_path,
						   cat_filemtime,
						   cat_d_images + cat_d_videos AS cat_d_items
					  FROM {categories}
					 WHERE cat_a_images + cat_d_images
					     + cat_a_videos + cat_d_videos > 0
					   AND ($sql_categories)";
			if (!DB::execute($sql, $params))
			{
				return DB::rollback(-1);
			}
			$childs_categories = [];
			foreach (DB::fetchAll('cat_id') as &$i)
			{
				$childs_categories[] = $i;
			}

			// On met à jour la colonne cat_lastpubdt des catégories et albums enfants.
			$childs_params = [];
			for ($n = 0; $n < count($childs_categories); $n++)
			{
				$sql = 'SELECT item_pubdt
						  FROM {items}
						 WHERE item_path LIKE ?
						   AND item_status = "1"
					  ORDER BY item_pubdt DESC
						 LIMIT 1';
				$item_path = DB::likeEscape($childs_categories[$n]['cat_path']) . '/%';
				if (!DB::execute($sql, $item_path))
				{
					return DB::rollback(-1);
				}
				$item_pubdt = DB::fetchVal();
				$item_pubdt =
					preg_match('`^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$`', (string) $item_pubdt)
					? $item_pubdt
					: NULL;
				$childs_params[] = [$item_pubdt, $childs_categories[$n]['cat_path']];
			}
			if (count($childs_params))
			{
				$sql = 'UPDATE {categories} SET cat_lastpubdt = ? WHERE cat_path = ?';
				if (!DB::execute($sql, $childs_params))
				{
					return DB::rollback(-1);
				}
			}

			// On met à jour les statistiques de toutes les sous-catégories.
			$s1 = $status ? 'd' : 'a';
			$s2 = $status ? 'a' : 'd';
			$sql = "UPDATE {categories}
					   SET cat_{$s2}_size = cat_{$s2}_size + cat_{$s1}_size,
						   cat_{$s2}_albums = cat_{$s2}_albums + cat_{$s1}_albums,
						   cat_{$s2}_images = cat_{$s2}_images + cat_{$s1}_images,
						   cat_{$s2}_videos = cat_{$s2}_videos + cat_{$s1}_videos,
						   cat_{$s2}_hits = cat_{$s2}_hits + cat_{$s1}_hits,
						   cat_{$s2}_comments = cat_{$s2}_comments + cat_{$s1}_comments,
						   cat_{$s2}_favorites = cat_{$s2}_favorites + cat_{$s1}_favorites,
						   cat_{$s2}_rating = CASE
								WHEN cat_{$s2}_votes + cat_{$s1}_votes > 0
								THEN ((cat_{$s2}_rating * cat_{$s2}_votes)
									+ (cat_{$s1}_rating * cat_{$s1}_votes))
									/ (cat_{$s2}_votes + cat_{$s1}_votes)
								ELSE 0
								 END,
						   cat_{$s2}_votes = cat_{$s2}_votes + cat_{$s1}_votes,
						   cat_{$s2}_subalbs = cat_{$s2}_subalbs + cat_{$s1}_subalbs,
						   cat_{$s2}_subcats = cat_{$s2}_subcats + cat_{$s1}_subcats,
						   cat_{$s1}_size = 0,
						   cat_{$s1}_albums = 0,
						   cat_{$s1}_images = 0,
						   cat_{$s1}_videos = 0,
						   cat_{$s1}_hits = 0,
						   cat_{$s1}_comments = 0,
						   cat_{$s1}_favorites = 0,
						   cat_{$s1}_rating = 0,
						   cat_{$s1}_votes = 0,
						   cat_{$s1}_subalbs = 0,
						   cat_{$s1}_subcats = 0,
						   cat_status = '$status'
					 WHERE $sql_categories";
			if (!DB::execute($sql, $params))
			{
				return DB::rollback(-1);
			}

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

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

		if ($nb_categories < 1)
		{
			return DB::rollback(0);
		}

		// 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_id_notify)
		{
			// Récupération du chemin des albums.
			$sql = 'SELECT cat_path
					  FROM {categories}
					 WHERE cat_id IN (' . DB::inInt($albums_id_notify) . ')
				  GROUP BY cat_path';
			if (DB::execute($sql))
			{
				// Envoi du courriel.
				if ($albums_notify = DB::fetchAll('cat_path', 'cat_path'))
				{
					$mail = new Mail();
					$mail->notify(
						'items',
						array_values($albums_notify),
						Auth::$id,
						[
							'user_id' => Auth::$id,
							'user_name' => Auth::$nickname
						]
					);
					$mail->send();
				}
			}
		}

		return $nb_categories;
	}

	/**
	 * Change la vignette de plusieurs catégories.
	 *
	 * @param array $change
	 *   $change =
	 *   [
	 *      'item' =>
	 *      [
	 *         // item_id_1 = id du fichier à utiliser comme vignette
	 *         item_id_1 => [cat_id_1, cat_id_2...],
	 *         ...
	 *      ],
	 *      method => // method = 'random', 'last' ou 'first'
	 *      [
	 *         // item_id_1 = id du fichier à exclure
	 *         item_id_1 => [cat_id_1, cat_id_2...],
	 *         ...
	 *      ],
	 *      method => [cat_id_1, cat_id_2...]
	 *   ]
	 * @param bool $subcats
	 *   Modifier aussi la vignette des sous-catégories ?
	 *
	 * @return int
	 *   Retourne le nombre de catégories affectées
	 *   ou -1 en cas d'erreur.
	 */
	public static function thumb(array $change, bool $subcats = FALSE): int
	{
		$update_params = [];

		// Identifiant de fichier fourni.
		if (isset($change['item']))
		{
			foreach ($change['item'] as $item_id => &$cat_ids)
			{
				if (!$cat_ids)
				{
					continue;
				}
				$sql = 'SELECT item_path, item_status FROM {items} WHERE item_id = ?';
				if (!DB::execute($sql, (int) $item_id))
				{
					return -1;
				}
				if (!$item_infos = DB::fetchRow())
				{
					continue;
				}
				$sql = 'SELECT cat_id,
							   thumb_id,
							   cat_path,
							   cat_status
						  FROM {categories}
						 WHERE cat_id IN (' . DB::inInt($cat_ids) . ')';
				if (!DB::execute($sql))
				{
					return -1;
				}
				$cat_infos = DB::fetchAll();
				foreach ($cat_infos as &$i)
				{
					$cat_path = $i['cat_path'] . '/';

					// On vérifie que le fichier n'est pas déjà utilisé comme
					// vignette de la catégorie, et que l'état du fichier
					// correspond à celui de la catégorie, et qu'il fait
					// partie de la catégorie.
					if ($item_id != $i['thumb_id']
					&& Item::getStatus($item_infos['item_status']) == $i['cat_status']
					&& substr($cat_path, 0, strlen($cat_path))
						== substr($item_infos['item_path'], 0, strlen($cat_path)))
					{
						$update_params[] = ['thumb_id' => $item_id, 'cat_id' => $i['cat_id']];
					}
				}
			}
		}

		// Choix d'une nouvelle vignette selon une méthode spécifiée.
		$add_subcats = function(array &$categories) use (&$subcats): void
		{
			if ($subcats && $categories)
			{
				$sql = 'SELECT cat_id,
							   cat_status,
							   thumb_id
						 FROM {categories}
						WHERE cat_parents LIKE ?';
				$cat_parents = [];
				foreach ($categories as &$i)
				{
					$cat_parents[] = ['%' . Parents::SEPARATOR . ((int) $i['cat_id'])
						. Parents::SEPARATOR . '%'];
				}
				$subcats = ['fetchAll'];
				if (!DB::execute($sql, $cat_parents, [], $subcats, TRUE))
				{
					return;
				}
				foreach ($subcats as &$cats)
				{
					foreach ($cats as &$i)
					{
						$categories[$i['cat_id']] = $i;
					}
				}
			}
		};
		$method_process = function(array &$cat_ids,
		string $method, int $item_id = 0) use (&$update_params, &$add_subcats): bool
		{
			if (!$cat_ids)
			{
				return TRUE;
			}
			switch ($method)
			{
				case 'first' : $sql_method = 'item_id ASC'; break;
				case 'last' : $sql_method = 'item_id DESC'; break;
				default : $sql_method = 'RAND()'; break;
			}
			$sql_where = ['cat_id IN (' . DB::inInt($cat_ids) . ')'];
			$params = [];
			if ($item_id > 0)
			{
				$sql_where[] = 'thumb_id = ?';
				$params = $item_id;
			}
			$sql = 'SELECT cat_id,
						   cat_status,
						   thumb_id
					  FROM {categories}
					 WHERE ' . implode(' AND ', $sql_where);
			if (!DB::execute($sql, $params))
			{
				return FALSE;
			}
			$categories = DB::fetchAll('cat_id');
			$add_subcats($categories);
			$params = [];
			foreach ($categories as &$i)
			{
				$p =
				[
					'id' => (int) $i['cat_id'],
					'parent' => '%' . Parents::SEPARATOR . ((int) $i['cat_id'])
						. Parents::SEPARATOR . '%',
					'status' => (int) $i['cat_status']
				];
				if ($method == 'random')
				{
					$p['thumb'] = $i['thumb_id'];
				}
				$params[] = $p;
			}
			if ($categories)
			{
				$sql_where =
				[
					"(cat_parents LIKE :parent OR cat_id = :id)",
					"item_status = :status"
				];
				if ($item_id > 0)
				{
					$sql_where[] = "item_id != $item_id";
				}
				if ($method == 'random')
				{
					$sql_where[] = "item_id != :thumb";
				}
				$sql_where = implode(' AND ', $sql_where);
				$sql = "SELECT item_id AS thumb_id,
							   :id + 0 AS cat_id
						  FROM {items}
					 LEFT JOIN {categories} USING (cat_id)
						 WHERE $sql_where
					  ORDER BY item_status DESC,
							   $sql_method
						 LIMIT 1";
				$cat_item = ['fetchRow'];
				if (!DB::execute($sql, $params, [], $cat_item, TRUE))
				{
					return FALSE;
				}
				$update_params = array_merge($update_params, $cat_item);
			}
			return TRUE;
		};
		foreach (['first', 'last', 'random'] as $method)
		{
			if (isset($change[$method]))
			{
				if (is_array($change[$method][key($change[$method])]))
				{
					foreach ($change[$method] as $item_id => &$cat_ids)
					{
						if (!$method_process($cat_ids, $method, (int) $item_id))
						{
							return -1;
						}
					}
				}
				else if (is_array($cat_ids = $change[$method]))
				{
					if (!$method_process($cat_ids, $method))
					{
						return -1;
					}
				}
			}
		}

		// Mise à jour de la base de données.
		if (!$update_params)
		{
			return 0;
		}
		$sql = 'UPDATE {categories}
				   SET thumb_id = :thumb_id,
				       cat_tb_params = NULL
				 WHERE cat_id = :cat_id
				   AND thumb_id != :thumb_id';
		if (!DB::execute($sql, $update_params))
		{
			return -1;
		}

		return DB::rowCount();
	}



	/**
	 * Modifie le nom de répertoire de toutes les sous-catégories d'une catégorie
	 * et retourne le nouveau nom de répertoire de la catégorie.
	 *
	 * @param array $i
	 *   Informations de la catégorie.
	 * @param string $new_dirname
	 *   Nouveau nom de répertoire.
	 *
	 * @return mixed
	 *   Retourne les informations du nouveau nom de répertoire dans un tableau,
	 *   ou FALSE si une erreur est survenue.
	 */
	private static function _changeDirname(array &$i, string $new_dirname)
	{
		// Nouveau nom de répertoire.
		$new_dirname = substr($new_dirname, 0, 220);
		$parent_path = dirname($i['cat_path']);
		$parents = $parent_path == '.' ? '' : $parent_path . '/';
		$parent_abs_path =  CONF_ALBUMS_PATH . '/' . $parents;

		$new_dirname = App::getValidDirname($new_dirname);
		if ($new_dirname === '' || $new_dirname == basename($i['cat_path']))
		{
			return [$i['cat_path']];
		}

		if (!(PHP_OS_FAMILY == 'Windows'
		&& strtolower($new_dirname) == strtolower(basename($i['cat_path']))))
		{
			$new_dirname = File::getSecureFilename($new_dirname, $parent_abs_path);
		}
		$new_path = $parents . $new_dirname;
		if ($new_path == $i['cat_path'])
		{
			return [$i['cat_path']];
		}

		// Modifications des informations des descendants.
		if (!self::_updateChilds($i, $new_path, [], $i['password_id']))
		{
			return FALSE;
		}

		return
		[
			$new_path,
			$parent_abs_path . basename($i['cat_path']),
			$parent_abs_path . basename($new_path)
		];
	}

	/**
	 * Modification du mot de passe.
	 *
	 * @param array $i
	 *   Informations de la catégorie.
	 * @param string $new_password
	 *   Nouveau mot de passe.
	 *
	 * @return mixed
	 *   Informations du mot de passe (array)
	 *   ou FALSE en cas d'erreur.
	 */
	private static function _changePassword(array &$i, string $new_password)
	{
		// Si aucun mot de passe n'est défini, inutile d'aller plus loin.
		if (preg_match('`^\*+$`', $new_password)
		|| ($new_password === '' && $i['password_id'] === NULL))
		{
			return ['change' => FALSE, 'id' => $i['password_id']];
		}

		$old_password_id = $password_id = $i['password_id'];
		$update_childs = FALSE;

		if ($i['parent_id'] > 1)
		{
			// Récupération du mot de passe de la catégorie parente.
			$sql = 'SELECT p.*
					  FROM {passwords} AS p
				 LEFT JOIN {categories} AS cat USING (password_id)
					 WHERE cat.cat_id = ?';
			if (!DB::execute($sql, $i['parent_id']))
			{
				return FALSE;
			}
			$parent_password = DB::fetchRow();

			// La catégorie parente possède-t-elle déjà un mot de passe ?
			if ($parent_password)
			{
				// Si oui, on reprend le mot de passe de la catégorie parente
				// si aucun mot de passe n'a été spécifié, ou
				// si le mot de passe de la catégorie parente est
				// le même que celui de la catégorie courante.
				if ($new_password === ''
				|| Security::passwordVerify($new_password, $parent_password['password_hash']))
				{
					$password_id = $parent_password['password_id'];
					$update_childs = TRUE;
				}

				// Si les deux mots de passe sont différents,
				// on en crée un nouveau pour la catégorie courante.
				else if ($parent_password['password_id'] == $password_id)
				{
					$password_id = NULL;
					$update_childs = TRUE;
				}
			}
		}

		// Suppression du mot de passe.
		if ($new_password === '' && !$update_childs)
		{
			if (!DB::execute('DELETE FROM {passwords} WHERE password_id = ?', $password_id))
			{
				return FALSE;
			}
			$password_id = NULL;
			$update_childs = TRUE;
		}

		// Modification du mot de passe.
		else if ($password_id)
		{
			$sql = 'UPDATE {passwords} SET password_hash = ? WHERE password_id = ?';
			$params = [Security::passwordHash($new_password), $password_id];
			if (!DB::execute($sql, $params))
			{
				return FALSE;
			}
		}

		// Ajout du nouveau mot de passe.
		else
		{
			$sql = 'INSERT INTO {passwords} (cat_id, password_hash) VALUES (?, ?)';
			$params = [$i['cat_id'], Security::passwordHash($new_password)];
			$seq = ['table' => '{passwords}', 'column' => 'password_id'];
			if (!DB::execute($sql, $params, $seq))
			{
				return FALSE;
			}
			$password_id = DB::lastInsertId();
			$update_childs = TRUE;
		}

		// On met à jour la catégorie courante
		// et toutes ses sous-catégories.
		if ($update_childs)
		{
			$sql = 'UPDATE {categories}
					   SET password_id = ?
					 WHERE (cat_parents LIKE ? OR cat_id = ?)
					   AND (password_id IS NULL OR password_id = ?)';
			$params =
			[
				$password_id,
				$i['cat_parents'] . $i['cat_id'] . Parents::SEPARATOR . '%',
				$i['cat_id'],
				$old_password_id
			];
			if (!DB::execute($sql, $params))
			{
				return FALSE;
			}
		}

		// On supprime les mots de passe de la table {passwords}
		// qui ne sont plus associés à une catégorie.
		$sql = 'SELECT DISTINCT password_id FROM {categories} WHERE password_id IS NOT NULL';
		$sql = "DELETE FROM {passwords} WHERE password_id NOT IN ($sql)";
		if (!DB::execute($sql))
		{
			return FALSE;
		}

		// On supprime les associations session - catégorie, afin de
		// déconnecter les utilisateurs qui auraient déverrouiller la
		// catégorie si celle-ci était déjà protégée par un mot de passe.
		if (!DB::execute('DELETE FROM {sessions_categories} WHERE cat_id = ?', $i['cat_id']))
		{
			return FALSE;
		}

		return ['change' => TRUE, 'id' => $password_id];
	}

	/**
	 * Retourne les informations utiles de toutes les catégories
	 * pour construire une liste des albums, en fonction des
	 * permissions d'accès de l'utilisateur.
	 *
	 * @param string $password
	 * @param string $perms
	 * @param string $order_by
	 *
	 * @return bool|array
	 *   Retourne FALSE en cas d'erreur.
	 */
	private static function _getCategories($password, $perms, $order_by)
	{
		$sql_order_by = SQL::catOrderBy($order_by);
		$sql = "SELECT cat.cat_id,
					   cat.user_id,
					   cat.password_id,
					   cat_parents,
					   cat_name,
					   cat_url,
					   cat_filemtime,
					   cat_creatable,
					   cat_uploadable,
					   cat_status,
					   cat_a_subcats + cat_d_subcats AS cat_subcats,
					   cat_a_subalbs + cat_d_subalbs AS cat_subalbs,
					   cat_a_images,
					   cat_a_videos,
					   cat_d_images,
					   cat_d_videos,
					   p.cat_id AS p_cat_id,
					   $password,
					   CASE WHEN cat.cat_id > 1
							THEN parent_id
							ELSE 0
							 END AS parent_id,
					   CASE WHEN cat_filemtime IS NULL
							THEN 'category'
							ELSE 'album'
							 END AS cat_type
				  FROM {categories} AS cat
			 LEFT JOIN {passwords} AS p USING (password_id)
				 WHERE $perms
			  ORDER BY $sql_order_by";
		if (!DB::execute($sql))
		{
			return FALSE;
		}
		return DB::fetchAll('cat_id');
	}

	/**
	 * Retourne les informations d'une liste de catégories
	 * groupées par leur catégorie parente.
	 *
	 * @param array $categories_id
	 *   Identifiants des catégories.
	 *
	 * @return array
	 */
	private static function _getCategoriesByParent(array $categories_id): array
	{
		$sql = 'SELECT cat.*,
		               p.cat_id AS p_cat_id
				  FROM {categories} AS cat
			 LEFT JOIN {passwords} AS p USING (password_id)
				 WHERE cat.cat_id IN (' . DB::inInt($categories_id) . ')
				   AND cat.cat_id > 1';
		if (!DB::execute($sql))
		{
			return [];
		}
		$categories_infos = DB::fetchAll('cat_id');

		// On réorganise les catégories par catégorie parente.
		$categories_by_parent = [];
		foreach ($categories_infos as $id => &$i)
		{
			// Si une catégorie de $categories_id est une sous-catégorie
			// d'une autre catégorie de $categories_id, on l'ignore.
			foreach (self::getParentsIdArray($i['cat_parents']) as &$parent_id)
			{
				if (isset($categories_infos[$parent_id]))
				{
					continue 2;
				}
			}

			$categories_by_parent[$i['parent_id']][$id] = $i;
		}

		return $categories_by_parent;
	}

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

		// Autres stats.
		$update['comments'] += $i["cat_{$s}_comments"];
		$update['favorites'] += $i["cat_{$s}_favorites"];
		$update['hits'] += $i["cat_{$s}_hits"];
		$update['images'] += $i["cat_{$s}_images"];
		$update['size'] += $i["cat_{$s}_size"];
		$update['videos'] += $i["cat_{$s}_videos"];
		$update['votes'] += $i["cat_{$s}_votes"];
	}

	/**
	 * Met à jour les informations suivantes pour une catégorie, ainsi
	 * que pour toutes les sous-catégories et fichiers qu'elle contient :
	 *
	 * - Chemin (cat_path et item_path).
	 * - Identifiants des catégories parentes (cat_parents).
	 * - Mot de passe (password_id).
	 *
	 * @param array $i
	 * @param string $new_path
	 * @param array $parents_id
	 * @param mixed $password_id
	 *
	 * @return bool
	 */
	private static function _updateChilds(array &$i, string $new_path,
	array $parents_id, $password_id): bool
	{
		$params =
		[
			'cat_id' => $i['cat_id'],
			'new_path' => $new_path,
			'old_path' => $i['cat_path'],
			'old_path_like' => DB::likeEscape($i['cat_path']) . '/%',
			'password_id' => $password_id
		];

		// Colonne 'cat_parents'.
		$sql_cat_parents = '';
		if ($parents_id)
		{
			$cat_parents = implode(Parents::SEPARATOR, $parents_id) . Parents::SEPARATOR;
			$cat_parents_childs = $cat_parents . $i['cat_id'] . Parents::SEPARATOR;
			$params['old_parents'] = $i['cat_parents'] . $i['cat_id'] . Parents::SEPARATOR;
			$params['new_parents'] = $cat_parents_childs;
			$sql_cat_parents = 'cat_parents
				= REPLACE("^"||cat_parents, "^"||:old_parents, :new_parents),';
		}

		// On modifie les informations de la catégorie
		// et de toutes les catégories descendantes.
		$sql = "UPDATE {categories}
				   SET $sql_cat_parents
				       cat_path = REPLACE('^'||cat_path, '^'||:old_path, :new_path),
				       password_id = :password_id
				 WHERE cat_id = :cat_id
					OR cat_path LIKE :old_path_like";
		if (!DB::execute($sql, $params))
		{
			return FALSE;
		}
		$categories_affected = DB::rowCount();

		// On vérifie que la colonne 'cat_parents'
		// des sous-catégories a bien été modifiée.
		if (CONF_DEV_MODE && $parents_id)
		{
			$sql = 'SELECT COUNT(cat_id) FROM {categories} WHERE cat_parents LIKE :cat_parents';
			$params =
			[
				'cat_parents' => $cat_parents_childs . '%',
			];
			if (!DB::execute($sql, $params))
			{
				return FALSE;
			}
			if (($count = DB::fetchVal()) != ($categories_affected - 1))
			{
				trigger_error('Error: ' . __LINE__, E_USER_WARNING);
				return FALSE;
			}
		}

		// On vérifie que le chemin des catégories a bien été changé.
		if (CONF_DEV_MODE)
		{
			$sql = 'SELECT COUNT(cat_id)
					  FROM {categories}
					 WHERE cat_id = :cat_id
						OR cat_path LIKE :new_path_like';
			$params =
			[
				'cat_id' => $i['cat_id'],
				'new_path_like' => DB::likeEscape($new_path) . '/%',
			];
			if (!DB::execute($sql, $params))
			{
				return FALSE;
			}
			if (($count = DB::fetchVal()) != $categories_affected)
			{
				trigger_error('Error: ' . __LINE__, E_USER_WARNING);
				return FALSE;
			}
		}

		// On modifie le chemin de tous les fichiers de la catégorie.
		$nb_items = $i['cat_a_images'] + $i['cat_d_images']
				  + $i['cat_a_videos'] + $i['cat_d_videos'];
		$sql = 'UPDATE {items}
				   SET item_path = REPLACE("^"||item_path, "^"||:old_path, :new_path)
				 WHERE item_path LIKE :old_path_like';
		$params =
		[
			'new_path' => $new_path,
			'old_path' => $i['cat_path'],
			'old_path_like' => DB::likeEscape($i['cat_path']) . '/%'
		];
		if (!DB::execute($sql, $params))
		{
			return FALSE;
		}

		if (CONF_DEV_MODE)
		{
			$row_count = DB::rowCount();
			if ($row_count != $nb_items)
			{
				trigger_error('Error: ' . __LINE__, E_USER_WARNING);
				return FALSE;
			}
		}

		return TRUE;
	}
}
?>