<?php
/* Copyright (C) 2005		Matthieu Valleton			<mv@seeschloss.org>
 * Copyright (C) 2005		Davoleau Brice				<brice.davoleau@gmail.com>
 * Copyright (C) 2005		Rodolphe Quiedeville		<rodolphe@quiedeville.org>
 * Copyright (C) 2006-2012	Regis Houssin				<regis.houssin@inodbox.com>
 * Copyright (C) 2006-2012	Laurent Destailleur			<eldy@users.sourceforge.net>
 * Copyright (C) 2007		Patrick Raguin				<patrick.raguin@gmail.com>
 * Copyright (C) 2013-2016	Juanjo Menent				<jmenent@2byte.es>
 * Copyright (C) 2013-2018	Philippe Grand				<philippe.grand@atoo-net.com>
 * Copyright (C) 2015		Marcos García				<marcosgdf@gmail.com>
 * Copyright (C) 2015		Raphaël Doursenaud			<rdoursenaud@gpcsolutions.fr>
 * Copyright (C) 2016-2024	Charlene Benke				<charlene@patas-monkey.com>
 * Copyright (C) 2018-2025	Frédéric France				<frederic.france@free.fr>
 * Copyright (C) 2022-2023	Solution Libre SAS			<contact@solution-libre.fr>
 * Copyright (C) 2023-2024	Benjamin Falière			<benjamin.faliere@altairis.fr>
 * Copyright (C) 2024-2025	MDW							<mdeweerd@users.noreply.github.com>
 * Copyright (C) 2025		Alexandre Spangaro			<alexandre@inovea-conseil.com>
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
 */

/**
 *	\file       htdocs/categories/class/categorie.class.php
 *	\ingroup    categorie
 *	\brief      File of class to manage categories
 */

require_once DOL_DOCUMENT_ROOT.'/core/class/commonobject.class.php';
require_once DOL_DOCUMENT_ROOT.'/product/class/product.class.php';
require_once DOL_DOCUMENT_ROOT.'/ticket/class/ticket.class.php';
require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.class.php';
require_once DOL_DOCUMENT_ROOT.'/contact/class/contact.class.php';
require_once DOL_DOCUMENT_ROOT.'/knowledgemanagement/class/knowledgerecord.class.php';


/**
 *	Class to manage categories
 */
class Categorie extends CommonObject
{
	// Categories types (we use string because we want to accept any modules/types in a future)
	const TYPE_PRODUCT				= 'product';
	const TYPE_SUPPLIER				= 'supplier';
	const TYPE_CUSTOMER				= 'customer';
	const TYPE_MEMBER				= 'member';
	const TYPE_CONTACT				= 'contact';
	const TYPE_USER					= 'user';
	const TYPE_PROJECT				= 'project';
	const TYPE_ACCOUNT				= 'bank_account';
	const TYPE_BANK_LINE			= 'bank_line';
	const TYPE_WAREHOUSE			= 'warehouse';
	const TYPE_ACTIONCOMM			= 'actioncomm';
	const TYPE_WEBSITE_PAGE			= 'website_page';
	const TYPE_TICKET				= 'ticket';
	const TYPE_KNOWLEDGEMANAGEMENT	= 'knowledgemanagement';
	const TYPE_FICHINTER			= 'fichinter';
	const TYPE_ORDER				= 'order';
	const TYPE_INVOICE				= 'invoice';
	const TYPE_SUPPLIER_ORDER		= 'supplier_order';
	const TYPE_SUPPLIER_INVOICE		= 'supplier_invoice';


	/**
	 * @var string String with name of icon for myobject. Must be the part after the 'object_' into object_myobject.png
	 */
	public $picto = 'category';


	/**
	 * @var array<string,int> 	Table of mapping between type string and type ID used for field 'type' in table llx_categories
	 */
	public $MAP_ID = array(
		'product'				=> 0,
		'supplier'				=> 1,
		'customer'				=> 2,
		'member'				=> 3,
		'contact'				=> 4,
		'bank_account'			=> 5,
		'project'				=> 6,
		'user'					=> 7,
		'bank_line'				=> 8,
		'warehouse'				=> 9,
		'actioncomm'			=> 10,
		'website_page'			=> 11,
		'ticket'				=> 12,
		'knowledgemanagement'	=> 13,
		'fichinter'				=> 14,
		'order'					=> 16,
		'invoice'				=> 17,
		'supplier_order'		=> 20,
		'supplier_invoice'		=> 21
	);

	/**
	 * @var array<int,string> 	Code mapping from ID
	 *
	 * @deprecated	This array should be removed in future, once previous constants are moved to the string value.
	 */
	public static $MAP_ID_TO_CODE = array(
		0  => 'product',
		1  => 'supplier',
		2  => 'customer',
		3  => 'member',
		4  => 'contact',
		5  => 'bank_account',
		6  => 'project',
		7  => 'user',
		8  => 'bank_line',
		9  => 'warehouse',
		10 => 'actioncomm',
		11 => 'website_page',
		12 => 'ticket',
		13 => 'knowledgemanagement',
		14 => 'fichinter',
		16 => 'order',
		17 => 'invoice',
		20 => 'supplier_order',
		21 => 'supplier_invoice'
	);

	/**
	 * @var array<string,string> Foreign keys mapping from type string when value does not match
	 *
	 * @todo Move to const array when PHP 5.6 will be our minimum target
	 */
	public $MAP_CAT_FK = array(
		'customer'     => 'soc',
		'supplier'     => 'soc',
		'contact'      => 'socpeople',
		'bank_account' => 'account',
	);

	/**
	 * @var array<string,string> Category tables mapping from type string (llx_categorie_...) when value does not match
	 *
	 * @note Move to const array when PHP 5.6 will be our minimum target
	 */
	public $MAP_CAT_TABLE = array(
		'customer'     => 'societe',
		'supplier'     => 'fournisseur',
		'bank_account' => 'account',
	);

	/**
	 * @var array<string,string> Object class mapping from type string
	 *
	 * @note Move to const array when PHP 5.6 will be our minimum target
	 */
	public $MAP_OBJ_CLASS = array(
		'product'				=> 'Product',
		'customer'				=> 'Societe',
		'supplier'				=> 'Fournisseur',
		'member'				=> 'Adherent',
		'contact'				=> 'Contact',
		'user'					=> 'User',
		'account'				=> 'Account', // old for bank account
		'bank_account'			=> 'Account',
		'project'				=> 'Project',
		'warehouse'				=> 'Entrepot',
		'actioncomm'			=> 'ActionComm',
		'website_page'			=> 'WebsitePage',
		'ticket'				=> 'Ticket',
		'knowledgemanagement'	=> 'KnowledgeRecord',
		'fichinter'				=> 'Fichinter',
		'order'					=> 'Commande',
		'invoice'				=> 'Facture',
		'supplier_order'		=> 'CommandeFournisseur',
		'supplier_invoice'		=> 'FactureFournisseur'
	);

	/**
	 * @var array<string,string> 	Title/Label mapping from type string
	 *
	 * @note Move to const array when PHP 5.6 will be our minimum target
	 */
	public static $MAP_TYPE_TITLE_AREA = array(
		'product'				=> 'Products',
		'customer'				=> 'ProspectsOrCustomers',
		'supplier'				=> 'Suppliers',
		'member'				=> 'Members',
		'contact'				=> 'Contacts',
		'user'					=> 'Users',
		'account'				=> 'Accounts', // old for bank account
		'bank_account'			=> 'BankAccounts',
		'bank_line'				=> 'BankTransactions',
		'project'				=> 'Projects',
		'warehouse'				=> 'Warehouse',
		'actioncomm'			=> 'AgendaEvents',
		'website_page'			=> 'WebsitePages',
		'ticket'				=> 'Tickets',
		'knowledgemanagement'	=> 'KnowledgeRecords',
		'fichinter'				=> 'Interventions',
		'order'					=> 'Orders',
		'invoice'				=> 'Invoices',
		'supplier_order'		=> 'SuppliersOrders',
		'supplier_invoice'		=> 'SuppliersInvoices'
	);

	/**
	 * @var array<string,string> 	Object table mapping from type string (table llx_...) when value of key does not match table name.
	 * 				This array may be completed by external modules with hook "constructCategory"
	 */
	public $MAP_OBJ_TABLE = array(
		'customer'				=> 'societe',
		'supplier'				=> 'societe',
		'member'				=> 'adherent',
		'contact'				=> 'socpeople',
		'account'				=> 'bank_account', // old for bank account
		'project'				=> 'projet',
		'warehouse'				=> 'entrepot',
		'knowledgemanagement'	=> 'knowledgemanagement_knowledgerecord',
		'fichinter'				=> 'fichinter',
		'order'					=> 'commande',
		'invoice'				=> 'facture',
		'supplier_order'		=> 'commande_fournisseur',
		'supplier_invoice'		=> 'facture_fourn'
	);

	/**
	 * @var string ID to identify managed object
	 */
	public $element = 'category';

	/**
	 * @var string Name of table without prefix where object is stored
	 */
	public $table_element = 'categorie';

	/**
	 * @var int ID
	 */
	public $fk_parent;

	/**
	 * @var string Category label
	 */
	public $label;

	/**
	 * @var string description
	 */
	public $description;

	/**
	 * @var string     Color
	 */
	public $color;

	/**
	 * @var int Position
	 */
	public $position;

	/**
	 * @var int Visible
	 */
	public $visible;

	/**
	 * @var int		  Id of thirdparty when CATEGORY_ASSIGNED_TO_A_CUSTOMER is set
	 */
	public $socid;

	/**
	 * @var string	Category type
	 *
	 * @see Categorie::TYPE_PRODUCT
	 * @see Categorie::TYPE_SUPPLIER
	 * @see Categorie::TYPE_CUSTOMER
	 * @see Categorie::TYPE_MEMBER
	 * @see Categorie::TYPE_CONTACT
	 * @see Categorie::TYPE_USER
	 * @see Categorie::TYPE_PROJECT
	 * @see Categorie::TYPE_ACCOUNT
	 * @see Categorie::TYPE_BANK_LINE
	 * @see Categorie::TYPE_WAREHOUSE
	 * @see Categorie::TYPE_ACTIONCOMM
	 * @see Categorie::TYPE_WEBSITE_PAGE
	 * @see Categorie::TYPE_TICKET
	 * @see Categorie::TYPE_FICHINTER
	 * @see Categorie::TYPE_ORDER
	 * @see Categorie::TYPE_INVOICE
	 * @see Categorie::TYPE_SUPPLIER_ORDER
	 * @see Categorie::TYPE_SUPPLIER_INVOICE
	 */
	public $type;

	/**
	 * @var array<int,array{rowid:int,id:int,fk_parent:int,label:string,description:string,color:string,position:string,visible:int,ref_ext:string,picto:string,fullpath:string,fulllabel:string,level:?int}>  Categories table in memory
	 */
	public $cats = array();

	/**
	 * @var array<int,int> Mother of table
	 */
	public $motherof = array();

	/**
	 * @var Categorie[] children
	 */
	public $childs = array();

	/**
	 * @var ?array<string,array{label:string,description:string,note?:string}>	Array for multilangs
	 */
	public $multilangs = array();

	/**
	 * @var int imgWidth
	 */
	public $imgWidth;

	/**
	 * @var int imgHeight
	 */
	public $imgHeight;

	/**
	 *  'type' if the field format ('integer', 'integer:ObjectClass:PathToClass[:AddCreateButtonOrNot[:Filter]]', 'varchar(x)', 'double(24,8)', 'real', 'price', 'text', 'html', 'date', 'datetime', 'timestamp', 'duration', 'mail', 'phone', 'url', 'password')
	 *         Note: Filter can be a string like "(t.ref:like:'SO-%') or (t.date_creation:<:'20160101') or (t.nature:is:NULL)"
	 *  'label' the translation key.
	 *  'enabled' is a condition when the field must be managed (Example: 1 or 'getDolGlobalString("MY_SETUP_PARAM")'
	 *  'position' is the sort order of field.
	 *  'notnull' is set to 1 if not null in database. Set to -1 if we must set data to null if empty ('' or 0).
	 *  'visible' says if field is visible in list (Examples: 0=Not visible, 1=Visible on list and create/update/view forms, 2=Visible on list only, 3=Visible on create/update/view form only (not list), 4=Visible on list and update/view form only (not create). 5=Visible on list and view only (not create/not update). Using a negative value means field is not shown by default on list but can be selected for viewing)
	 *  'noteditable' says if field is not editable (1 or 0)
	 *  'default' is a default value for creation (can still be overwrote by the Setup of Default Values if field is editable in creation form). Note: If default is set to '(PROV)' and field is 'ref', the default value will be set to '(PROVid)' where id is rowid when a new record is created.
	 *  'index' if we want an index in database.
	 *  'foreignkey'=>'tablename.field' if the field is a foreign key (it is recommended to name the field fk_...).
	 *  'searchall' is 1 if we want to search in this field when making a search from the quick search button.
	 *  'isameasure' must be set to 1 if you want to have a total on list for this field. Field type must be summable like integer or double(24,8).
	 *  'css' is the CSS style to use on field. For example: 'maxwidth200'
	 *  'help' is a string visible as a tooltip on field
	 *  'showoncombobox' if value of the field must be visible into the label of the combobox that list record
	 *  'disabled' is 1 if we want to have the field locked by a 'disabled' attribute. In most cases, this is never set into the definition of $fields into class, but is set dynamically by some part of code.
	 *  'arrayofkeyval' to set list of value if type is a list of predefined values. For example: array("0"=>"Draft","1"=>"Active","-1"=>"Cancel")
	 *  'autofocusoncreate' to have field having the focus on a create form. Only 1 field should have this property set to 1.
	 *  'comment' is not used. You can store here any text of your choice. It is not used by application.
	 *
	 *  Note: To have value dynamic, you can set value to 0 in definition and edit the value on the fly into the constructor.
	 */
	public $fields = array(
		'rowid' => array('type' => 'integer', 'label' => 'TechnicalID', 'enabled' => 1, 'position' => 10, 'notnull' => 1, 'visible' => 0,),
		'entity'	=> array('type' => 'integer', 'label' => 'Entity', 'enabled' => 1, 'visible' => 0, 'default' => '1', 'notnull' => 1, 'index' => 1, 'position' => 5),
		'fk_parent' => array('type' => 'integer', 'label' => 'ParentCategory', 'enabled' => 1, 'position' => 20, 'notnull' => 1, 'visible' => 0, 'css' => 'maxwidth500 widthcentpercentminusxx',),
		'label' => array('type' => 'varchar(180)', 'label' => 'Ref', 'enabled' => 1, 'position' => 25, 'notnull' => 1, 'visible' => 1, 'alwayseditable' => 1, 'css' => 'minwidth300', 'cssview' => 'wordbreak', 'csslist' => 'tdoverflowmax150', 'showoncombobox' => 1),
		'ref_ext' => array('type' => 'varchar(255)', 'label' => 'RefExt', 'enabled' => 1, 'position' => 30, 'notnull' => 0, 'visible' => 0, 'alwayseditable' => 1,),
		'type' => array('type' => 'integer', 'label' => 'Type', 'enabled' => 1, 'position' => 35, 'notnull' => 1, 'visible' => 0, 'alwayseditable' => 1,),
		'description' => array('type' => 'text', 'label' => 'Description', 'enabled' => 1, 'position' => 40, 'notnull' => 0, 'visible' => 1, 'alwayseditable' => 1,),
		'color' => array('type' => 'varchar(8)', 'label' => 'Color', 'enabled' => 1, 'position' => 45, 'notnull' => 0, 'visible' => 1, 'alwayseditable' => 1,),
		'position' => array('type' => 'integer', 'label' => 'Position', 'enabled' => 1, 'position' => 50, 'notnull' => 0, 'visible' => -1, 'alwayseditable' => 1,),
		'fk_soc' => array('type' => 'integer:Societe:societe/class/societe.class.php', 'label' => 'ThirdParty', 'picto' => 'company', 'enabled' => 1, 'position' => 55, 'notnull' => 0, 'visible' => 0, 'alwayseditable' => 1, 'css' => 'maxwidth500 widthcentpercentminusxx', 'csslist' => 'tdoverflowmax150',),
		'visible' => array('type' => 'integer', 'label' => 'Visible', 'enabled' => 1, 'position' => 60, 'notnull' => 1, 'visible' => 0, 'alwayseditable' => 1,),
		'import_key' => array('type' => 'varchar(14)', 'label' => 'ImportId', 'enabled' => 1, 'position' => 900, 'notnull' => 0, 'visible' => -2, 'alwayseditable' => 1,),
		'date_creation' => array('type' => 'datetime', 'label' => 'DateCreation', 'enabled' => 1, 'position' => 70, 'notnull' => 0, 'visible' => -1, 'alwayseditable' => 1,),
		'tms' => array('type' => 'timestamp', 'label' => 'DateModification', 'enabled' => 1, 'position' => 75, 'notnull' => 1, 'visible' => -1, 'alwayseditable' => 1,),
		'fk_user_creat' => array('type' => 'integer:User:user/class/user.class.php', 'label' => 'UserAuthor', 'enabled' => 1, 'position' => 80, 'notnull' => 0, 'visible' => -2, 'alwayseditable' => 1, 'css' => 'maxwidth500 widthcentpercentminusxx', 'csslist' => 'tdoverflowmax150',),
		'fk_user_modif' => array('type' => 'integer:User:user/class/user.class.php', 'label' => 'UserModif', 'enabled' => 1, 'position' => 85, 'notnull' => -1, 'visible' => -2, 'alwayseditable' => 1, 'css' => 'maxwidth500 widthcentpercentminusxx', 'csslist' => 'tdoverflowmax150',),
	);

	/**
	 *	Constructor
	 *
	 *  @param		DoliDB		$db     Database handler
	 */
	public function __construct($db)
	{
		global $hookmanager;

		$this->db = $db;

		$this->ismultientitymanaged = 1;
		$this->isextrafieldmanaged = 1;

		if (is_object($hookmanager)) {
			$hookmanager->initHooks(array('category'));
			$parameters = array();
			$reshook = $hookmanager->executeHooks('constructCategory', $parameters, $this); // Note that $action and $object may have been modified by some hooks
			if ($reshook >= 0 && !empty($hookmanager->resArray)) {
				foreach ($hookmanager->resArray as $mapList) {
					$mapId = $mapList['id'];
					$mapCode = $mapList['code'];
					self::$MAP_ID_TO_CODE[$mapId] = $mapCode;
					$this->MAP_ID[$mapCode] = $mapId;
					$this->MAP_CAT_FK[$mapCode] = isset($mapList['cat_fk']) ? $mapList['cat_fk'] : null;
					$this->MAP_CAT_TABLE[$mapCode] = isset($mapList['cat_table']) ? $mapList['cat_table'] : null;
					$this->MAP_OBJ_CLASS[$mapCode] = $mapList['obj_class'];
					$this->MAP_OBJ_TABLE[$mapCode] = $mapList['obj_table'];
				}
			}
		}
	}

	/**
	 * Get map list
	 *
	 * @return	array<array{id:int,code:string,cat_fk:string,cat_table:string,obj_class:string,obj_table:string}>
	 */
	public function getMapList()
	{
		$mapList = array();

		foreach ($this->MAP_ID as $mapCode => $mapId) {
			$mapList[] = array(
				'id'        => $mapId,
				'code'      => $mapCode,
				'cat_fk'    => (empty($this->MAP_CAT_FK[$mapCode]) ? $mapCode : $this->MAP_CAT_FK[$mapCode]),
				'cat_table' => (empty($this->MAP_CAT_TABLE[$mapCode]) ? $mapCode : $this->MAP_CAT_TABLE[$mapCode]),
				'obj_class' => (empty($this->MAP_OBJ_CLASS[$mapCode]) ? $mapCode : $this->MAP_OBJ_CLASS[$mapCode]),
				'obj_table' => (empty($this->MAP_OBJ_TABLE[$mapCode]) ? $mapCode : $this->MAP_OBJ_TABLE[$mapCode])
			);
		}

		return $mapList;
	}

	/**
	 * Get MAP_ID
	 *
	 * @return	array<string,int>
	 */
	public function getMapId()
	{
		return $this->MAP_ID;
	}

	/**
	 * 	Load category into memory from database
	 *
	 * 	@param		int		$id      Id of category
	 *  @param		string	$label   Label of category
	 *  @param		string	$type    Type of category ('product', '...') or (0, 1, ...)
	 *  @param		string	$ref_ext External reference of object
	 * 	@return		int				Return integer <0 if KO, >0 if OK
	 */
	public function fetch($id, $label = '', $type = null, $ref_ext = '')
	{
		// Check parameters
		if (empty($id) && empty($label) && empty($ref_ext)) {
			$this->error = "No category to search for";
			return -1;
		}
		if (!is_null($type) && !is_numeric($type)) {
			$type = $this->MAP_ID[$type];
		}

		$sql = "SELECT rowid, fk_parent, entity, label, description, color, position, fk_soc, visible, type, ref_ext";
		$sql .= ", date_creation, tms, fk_user_creat, fk_user_modif";
		$sql .= " FROM ".MAIN_DB_PREFIX."categorie";
		if ($id) {
			$sql .= " WHERE rowid = ".((int) $id);
		} elseif (!empty($ref_ext)) {
			$sql .= " WHERE ref_ext LIKE '".$this->db->escape($ref_ext)."'";
		} else {
			$sql .= " WHERE label = '".$this->db->escape($label)."' AND entity IN (".getEntity('category').")";
			if (!is_null($type)) {
				$sql .= " AND type = ".((int) $type);
			}
		}

		dol_syslog(get_class($this)."::fetch", LOG_DEBUG);
		$resql = $this->db->query($sql);
		if ($resql) {
			if ($this->db->num_rows($resql) > 0 && $res = $this->db->fetch_array($resql)) {
				$this->id = $res['rowid'];
				//$this->ref = $res['rowid'];
				$this->fk_parent = (int) $res['fk_parent'];
				$this->label = $res['label'];
				$this->description = $res['description'];
				$this->color = $res['color'];
				$this->position = $res['position'];
				$this->socid = (int) $res['fk_soc'];
				$this->visible = (int) $res['visible'];
				$this->type = $res['type'];
				$this->ref_ext = $res['ref_ext'];
				$this->entity = (int) $res['entity'];
				$this->date_creation = $this->db->jdate($res['date_creation']);
				$this->date_modification = $this->db->jdate($res['tms']);
				$this->user_creation_id = (int) $res['fk_user_creat'];
				$this->user_modification_id = (int) $res['fk_user_modif'];

				// Retrieve all extrafield
				// fetch optionals attributes and labels
				$this->fetch_optionals();

				$this->db->free($resql);

				// multilangs
				if (getDolGlobalInt('MAIN_MULTILANGS')) {
					$this->getMultiLangs();
				}

				return 1;
			} else {
				$this->error = "No category found";
				return 0;
			}
		} else {
			dol_print_error($this->db);
			$this->error = $this->db->lasterror;
			$this->errors[] = $this->db->lasterror;
			return -1;
		}
	}

	/**
	 *  Add category into database
	 *
	 *  @param	User	$user		Object user
	 *  @param	int		$notrigger	1=Does not execute triggers, 0= execute triggers
	 *  @return	int 				-1 : SQL error
	 *          					-2 : new ID unknown
	 *          					-3 : Invalid category
	 * 								-4 : category already exists
	 */
	public function create($user, $notrigger = 0)
	{
		global $conf, $langs;
		$langs->load('categories');

		$type = $this->type;

		if (!is_numeric($type)) {
			$type = $this->MAP_ID[$type];
		}

		$error = 0;

		dol_syslog(get_class($this).'::create', LOG_DEBUG);

		// Clean parameters
		$this->label = trim($this->label);
		$this->description = trim($this->description);
		$this->color = trim($this->color);
		$this->position = (int) $this->position;
		if (isset($this->import_key)) {
			$this->import_key = trim($this->import_key);
		}
		$this->ref_ext = trim($this->ref_ext);
		if (empty($this->visible)) {
			$this->visible = 0;
		}
		$this->fk_parent = ($this->fk_parent != "" ? intval($this->fk_parent) : 0);

		if ($this->already_exists()) {
			$this->error = $langs->trans("ImpossibleAddCat", $this->label);
			$this->error .= " : ".$langs->trans("CategoryExistsAtSameLevel");
			dol_syslog($this->error, LOG_WARNING);
			return -4;
		}

		$this->db->begin();
		$now = dol_now();
		$sql = "INSERT INTO ".MAIN_DB_PREFIX."categorie (";
		$sql .= "fk_parent,";
		$sql .= " label,";
		$sql .= " description,";
		$sql .= " color,";
		$sql .= " position,";
		if (getDolGlobalString('CATEGORY_ASSIGNED_TO_A_CUSTOMER')) {
			$sql .= "fk_soc,";
		}
		$sql .= " visible,";
		$sql .= " type,";
		$sql .= " import_key,";
		$sql .= " ref_ext,";
		$sql .= " entity,";
		$sql .= " date_creation,";
		$sql .= " fk_user_creat";
		$sql .= ") VALUES (";
		$sql .= (int) $this->fk_parent.",";
		$sql .= "'".$this->db->escape($this->label)."', ";
		$sql .= "'".$this->db->escape($this->description)."', ";
		$sql .= "'".$this->db->escape($this->color)."', ";
		$sql .= (int) $this->position.",";
		if (getDolGlobalString('CATEGORY_ASSIGNED_TO_A_CUSTOMER')) {
			$sql .= ($this->socid > 0 ? $this->socid : 'null').", ";
		}
		$sql .= "'".$this->db->escape((string) $this->visible)."', ";
		$sql .= ((int) $type).", ";
		$sql .= (!empty($this->import_key) ? "'".$this->db->escape($this->import_key)."'" : 'null').", ";
		$sql .= (!empty($this->ref_ext) ? "'".$this->db->escape($this->ref_ext)."'" : 'null').", ";
		$sql .= (int) $conf->entity.", ";
		$sql .= "'".$this->db->idate($now)."', ";
		$sql .= (int) $user->id;
		$sql .= ")";

		$res = $this->db->query($sql);
		if ($res) {
			$id = $this->db->last_insert_id(MAIN_DB_PREFIX."categorie");

			if ($id > 0) {
				$this->id = $id;

				$action = 'create';

				// Actions on extra fields
				$result = $this->insertExtraFields();
				if ($result < 0) {
					$error++;
				}

				if (!$error && !$notrigger) {
					// Call trigger
					$result = $this->call_trigger('CATEGORY_CREATE', $user);
					if ($result < 0) {
						$error++;
					}
					// End call triggers
				}

				if (!$error) {
					$this->db->commit();
					return $id;
				} else {
					$this->db->rollback();
					return -3;
				}
			} else {
				$this->db->rollback();
				return -2;
			}
		} else {
			$this->error = $this->db->error();
			$this->db->rollback();
			return -1;
		}
	}

	/**
	 * 	Update category
	 *
	 *	@param	User	$user		Object user
	 *  @param	int		$notrigger	1=Does not execute triggers, 0= execute triggers
	 * 	@return	int		 			1 : OK
	 *          					-1 : SQL error
	 *          					-2 : invalid category
	 */
	public function update(User $user, $notrigger = 0)
	{
		global $langs;

		$error = 0;

		// Clean parameters
		$this->label = trim($this->label);
		$this->description = trim($this->description);
		$this->ref_ext = trim($this->ref_ext);
		$this->fk_parent = ($this->fk_parent != "" ? intval($this->fk_parent) : 0);
		$this->visible = ($this->visible != "" ? intval($this->visible) : 0);

		if ($this->already_exists()) {
			$this->error = $langs->trans("ImpossibleUpdateCat");
			$this->error .= " : ".$langs->trans("CategoryExistsAtSameLevel");
			return -1;
		}

		$this->db->begin();

		$sql = "UPDATE ".MAIN_DB_PREFIX."categorie";
		$sql .= " SET label = '".$this->db->escape($this->label)."',";
		$sql .= " description = '".$this->db->escape($this->description)."',";
		$sql .= " ref_ext = '".$this->db->escape($this->ref_ext)."',";
		$sql .= " color = '".$this->db->escape($this->color)."'";
		$sql .= ", position = ".(int) $this->position;
		if (getDolGlobalString('CATEGORY_ASSIGNED_TO_A_CUSTOMER')) {
			$sql .= ", fk_soc = ".($this->socid > 0 ? $this->socid : 'null');
		}
		$sql .= ", visible = ".(int) $this->visible;
		$sql .= ", fk_parent = ".(int) $this->fk_parent;
		$sql .= ", fk_user_modif = ".(int) $user->id;
		$sql .= " WHERE rowid = ".((int) $this->id);

		dol_syslog(get_class($this)."::update", LOG_DEBUG);
		if ($this->db->query($sql)) {
			$action = 'update';

			// Actions on extra fields
			$result = $this->insertExtraFields();
			if ($result < 0) {
				$error++;
			}

			if (!$error && !$notrigger) {
				// Call trigger
				$result = $this->call_trigger('CATEGORY_MODIFY', $user);
				if ($result < 0) {
					$error++;
				}
				// End call triggers
			}

			if (!$error) {
				$this->db->commit();
				return 1;
			} else {
				$this->db->rollback();
				return -1;
			}
		} else {
			$this->db->rollback();
			dol_print_error($this->db);
			return -1;
		}
	}

	/**
	 * 	Delete a category from database
	 *
	 * 	@param	User	$user		Object user that ask to delete
	 *	@param	int		$notrigger	1=Does not execute triggers, 0= execute triggers
	 *	@return	int                 Return integer <0 KO >0 OK
	 */
	public function delete($user, $notrigger = 0)
	{
		$error = 0;

		// Clean parameters
		$this->fk_parent = ($this->fk_parent != "" ? intval($this->fk_parent) : 0);

		dol_syslog(get_class($this)."::remove");

		$this->db->begin();

		if (/* !$error && */ !$notrigger) {
			// Call trigger
			$result = $this->call_trigger('CATEGORY_DELETE', $user);
			if ($result < 0) {
				$error++;
			}
			// End call triggers
		}

		/* FIX #1317 : Check for child category and move up 1 level*/
		if (!$error) {
			$sql = "UPDATE ".MAIN_DB_PREFIX."categorie";
			$sql .= " SET fk_parent = ".((int) $this->fk_parent);
			$sql .= " WHERE fk_parent = ".((int) $this->id);

			if (!$this->db->query($sql)) {
				$this->error = $this->db->lasterror();
				$error++;
			}
		}

		$arraydelete = array(
			'categorie_account' => 'fk_categorie',
			'categorie_actioncomm' => 'fk_categorie',
			'categorie_contact' => 'fk_categorie',
			'categorie_fournisseur' => 'fk_categorie',
			'categorie_knowledgemanagement' => array('field' => 'fk_categorie', 'enabled' => isModEnabled('knowledgemanagement')),
			'categorie_fichinter' => array('field' => 'fk_categorie', 'enabled' => false ),  // not yet fully implemented (wait 22) isModEnabled('intervention')),
			'categorie_member' => 'fk_categorie',
			'categorie_user' => 'fk_categorie',
			'categorie_product' => 'fk_categorie',
			'categorie_project' => 'fk_categorie',
			'categorie_societe' => 'fk_categorie',
			'categorie_ticket' => array('field' => 'fk_categorie', 'enabled' => isModEnabled('ticket')),
			'categorie_warehouse' => 'fk_categorie',
			'categorie_website_page' => array('field' => 'fk_categorie', 'enabled' => isModEnabled('website')),
			'category_bankline' => 'fk_categ',
			'categorie_lang' => 'fk_category',
			'categorie' => 'rowid',
		);
		foreach ($arraydelete as $key => $value) {
			if (is_array($value)) {
				if (empty($value['enabled'])) {
					continue;
				}
				$value = $value['field'];
			}
			$sql  = "DELETE FROM ".MAIN_DB_PREFIX.$key;
			$sql .= " WHERE ".$value." = ".((int) $this->id);
			if (!$this->db->query($sql)) {
				$this->errors[] = $this->db->lasterror();
				dol_syslog("Error sql=".$sql." ".$this->error, LOG_ERR);
				$error++;
			}
		}

		// Removed extrafields
		if (!$error) {
			$result = $this->deleteExtraFields();
			if ($result < 0) {
				$error++;
				dol_syslog(get_class($this)."::delete erreur ".$this->error, LOG_ERR);
			}
		}

		if (!$error) {
			$this->db->commit();
			return 1;
		} else {
			$this->db->rollback();
			return -1;
		}
	}

	// phpcs:disable PEAR.NamingConventions.ValidFunctionName.ScopeNotCamelCaps
	/**
	 * Link an object to the category
	 *
	 * @param   CommonObject 	$obj  	Object to link to category
	 * @param   string     		$type 	Type of category ('product', ...). Use '' to take $obj->element.
	 * @return  int                		1 : OK, -1 : erreur SQL, -2 : id not defined, -3 : Already linked
	 * @see del_type()
	 */
	public function add_type($obj, $type = '')
	{
		// phpcs:enable
		global $user;

		$error = 0;

		if ($this->id == -1) {
			return -2;
		}

		if (empty($type)) {
			$type = $obj->element;
		}

		dol_syslog(get_class($this).'::add_type', LOG_DEBUG);

		$this->db->begin();

		$sql = "INSERT INTO ".MAIN_DB_PREFIX."categorie_".(empty($this->MAP_CAT_TABLE[$type]) ? $type : $this->MAP_CAT_TABLE[$type]);
		$sql .= " (fk_categorie, fk_".(empty($this->MAP_CAT_FK[$type]) ? $type : $this->MAP_CAT_FK[$type]).")";
		$sql .= " VALUES (".((int) $this->id).", ".((int) $obj->id).")";

		if ($this->db->query($sql)) {
			if (getDolGlobalString('CATEGORIE_RECURSIV_ADD')) {
				$sql = 'SELECT fk_parent FROM '.MAIN_DB_PREFIX.'categorie';
				$sql .= " WHERE rowid = ".((int) $this->id);

				dol_syslog(get_class($this)."::add_type", LOG_DEBUG);
				$resql = $this->db->query($sql);
				if ($resql) {
					if ($this->db->num_rows($resql) > 0) {
						$objparent = $this->db->fetch_object($resql);

						if (!empty($objparent->fk_parent)) {
							$cat = new Categorie($this->db);
							$cat->id = $objparent->fk_parent;
							if (!$cat->containsObject($type, $obj->id)) {
								$result = $cat->add_type($obj, $type);
								if ($result < 0) {
									$this->error = $cat->error;
									$error++;
								}
							}
						}
					}
				} else {
					$error++;
					$this->error = $this->db->lasterror();
				}

				if ($error) {
					$this->db->rollback();
					return -1;
				}
			}

			// Call trigger
			$this->context = array('linkto' => $obj); // Save object we want to link category to into category instance to provide information to trigger
			$result = $this->call_trigger('CATEGORY_MODIFY', $user);
			if ($result < 0) {
				$error++;
			}
			// End call triggers

			if (!$error) {
				$this->db->commit();
				return 1;
			} else {
				$this->db->rollback();
				return -2;
			}
		} else {
			$this->db->rollback();
			if ($this->db->lasterrno() == 'DB_ERROR_RECORD_ALREADY_EXISTS') {
				$this->error = $this->db->lasterrno();
				return -3;
			} else {
				$this->error = $this->db->lasterror();
			}
			return -1;
		}
	}

	// phpcs:disable PEAR.NamingConventions.ValidFunctionName.ScopeNotCamelCaps
	/**
	 * Delete object from category
	 *
	 * @param   CommonObject $obj  Object
	 * @param   string       $type Type of category ('customer', 'supplier', 'contact', 'product', 'member')
	 * @return  int          1 if OK, -1 if KO
	 * @see add_type()
	 */
	public function del_type($obj, $type)
	{
		// phpcs:enable
		global $user;

		$error = 0;

		// For backward compatibility
		if ($type == 'societe') {
			$type = 'customer';
			dol_syslog(get_class($this)."::del_type(): type 'societe' is deprecated, please use 'customer' instead", LOG_WARNING);
		} elseif ($type == 'fournisseur') {
			$type = 'supplier';
			dol_syslog(get_class($this)."::del_type(): type 'fournisseur' is deprecated, please use 'supplier' instead", LOG_WARNING);
		}

		$this->db->begin();

		$sql = "DELETE FROM ".MAIN_DB_PREFIX."categorie_".(empty($this->MAP_CAT_TABLE[$type]) ? $type : $this->MAP_CAT_TABLE[$type]);
		$sql .= " WHERE fk_categorie = ".((int) $this->id);
		$sql .= " AND fk_".(empty($this->MAP_CAT_FK[$type]) ? $type : $this->MAP_CAT_FK[$type])." = ".((int) $obj->id);

		dol_syslog(get_class($this).'::del_type', LOG_DEBUG);
		if ($this->db->query($sql)) {
			// Call trigger
			$this->context = array('unlinkoff' => $obj); // Save object we want to link category to into category instance to provide information to trigger
			$result = $this->call_trigger('CATEGORY_MODIFY', $user);
			if ($result < 0) {
				$error++;
			}
			// End call triggers

			if (!$error) {
				$this->db->commit();
				return 1;
			} else {
				$this->db->rollback();
				return -2;
			}
		} else {
			$this->db->rollback();
			$this->error = $this->db->lasterror();
			return -1;
		}
	}

	/**
	 * Return list of fetched instances of elements having the current category.
	 * WARNING: Do not use this. It can return an array with a very high number of element making an out of memory. Try by using instead getListForItem() or containing().
	 *
	 * @param   string     	$type       	Type of category ('customer', 'supplier', 'contact', 'product', 'member', 'knowledge_management', ...)
	 * @param   int        	$onlyids    	Return only ids of objects (consume less memory)
	 * @param	int			$limit			Limit
	 * @param	int			$offset			Offset
	 * @param	string		$sortfield		Sort fields
	 * @param	string		$sortorder		Sort order ('ASC' or 'DESC');
	 * @param  	string		$filter       	Filter as an Universal Search string.
	 * 										Example: 	'((client:=:1) OR ((client:>=:2) AND (client:<=:3))) AND (client:!=:8) AND (nom:like:'a%')'
	 * 										For multilingual fields, use: 	'(ol.label:like:'%value%')' and set $filterlang parameter
	 * @param  	string      $filtermode   	No more used
	 * @param   string		$filterlang     Language to use in Universal Search for multilingual fields ('fr_FR', 'en_US'...)
	 * @return  CommonObject[]|int[]|int    Return -1 if KO, array of instance of object if OK
	 * @see containsObject()
	 */
	public function getObjectsInCateg($type, $onlyids = 0, $limit = 0, $offset = 0, $sortfield = '', $sortorder = 'ASC', $filter = '', $filtermode = 'AND', $filterlang = '')
	{
		global $user;

		if (empty($onlyids)) {
			dol_syslog("getObjectsInCateg: This method used with parameter onlyids=0 is deprecated. Try by using instead getListForItem().", LOG_WARNING);
		}

		$objs = array();

		$classnameforobj = $this->MAP_OBJ_CLASS[$type];
		if (!empty($classnameforobj) && class_exists($classnameforobj)) {
			$tmpobj = new $classnameforobj($this->db);
			/** @var CommonObject $tmpobj */

			$sql = "SELECT c.fk_".(empty($this->MAP_CAT_FK[$type]) ? $type : $this->MAP_CAT_FK[$type])." as fk_object";
			$sql .= " FROM ".MAIN_DB_PREFIX."categorie_".(empty($this->MAP_CAT_TABLE[$type]) ? $type : $this->MAP_CAT_TABLE[$type])." as c";
			$sql .= ", ".MAIN_DB_PREFIX.(empty($this->MAP_OBJ_TABLE[$type]) ? $type : $this->MAP_OBJ_TABLE[$type])." as o";
			if (!empty($filterlang)) {
				$sql .= ", ".MAIN_DB_PREFIX.(empty($this->MAP_OBJ_TABLE[$type]) ? $type : $this->MAP_OBJ_TABLE[$type])."_lang as ol";
			}
			$sql .= " WHERE o.entity IN (".getEntity($tmpobj->element).")";
			$sql .= " AND c.fk_categorie = ".((int) $this->id);
			// Compatibility with actioncomm table which has id instead of rowid
			if ((array_key_exists($type, $this->MAP_OBJ_TABLE) && $this->MAP_OBJ_TABLE[$type] == "actioncomm") || $type == "actioncomm") {
				$sql .= " AND c.fk_".(empty($this->MAP_CAT_FK[$type]) ? $type : $this->MAP_CAT_FK[$type])." = o.id";
			} else {
				$sql .= " AND c.fk_".(empty($this->MAP_CAT_FK[$type]) ? $type : $this->MAP_CAT_FK[$type])." = o.rowid";
			}
			if (!empty($filterlang)) {
				$sql .= " AND ol.fk_".(empty($this->MAP_OBJ_TABLE[$type]) ? $type : $this->MAP_OBJ_TABLE[$type])." = o.rowid";
				$sql .= " AND ol.lang = '".$this->db->escape($filterlang)."'";
			}
			// Protection for external users
			if (($type == 'customer' || $type == 'supplier') && $user->socid > 0) {
				$sql .= " AND o.rowid = ".((int) $user->socid);
			}

			$errormessage = '';
			$sql .= forgeSQLFromUniversalSearchCriteria($filter, $errormessage);
			if ($errormessage) {
				$this->errors[] = $errormessage;
				dol_syslog(__METHOD__.' '.implode(',', $this->errors), LOG_ERR);
				return -1;
			}

			$sql .= $this->db->order($sortfield, $sortorder);
			if ($limit > 0 || $offset > 0) {
				$sql .= $this->db->plimit($limit + 1, $offset);
			}

			dol_syslog(get_class($this)."::getObjectsInCateg", LOG_DEBUG);

			$resql = $this->db->query($sql);
			if ($resql) {
				while ($rec = $this->db->fetch_array($resql)) {
					if ($onlyids) {
						$objs[] = $rec['fk_object'];
					} else {
						$tmpobj->id = 0;
						$tmpobj->fetch($rec['fk_object']);	// The fetch will erase $tmpobj->id only if it succeed.
						// @phpstan-ignore-next-line
						if ($tmpobj->id > 0) {		// Failing fetch may happen for example when a category supplier was set and third party was moved as customer only. The object supplier can't be loaded.
							$objs[] = $tmpobj;
						}
					}
				}
			} else {
				$this->error = $this->db->error().' sql='.$sql;
				return -1;
			}
		}

		return $objs;
	}

	/**
	 * Check for the presence of a given object in the current category
	 *
	 * @param   string $type      		Type of category ('customer', 'supplier', 'contact', 'product', 'member')
	 * @param   int    $object_id 		Id of the object to search
	 * @return  int                     Number of occurrences
	 * @see getObjectsInCateg()
	 */
	public function containsObject($type, $object_id)
	{
		$sql = "SELECT COUNT(*) as nb FROM ".MAIN_DB_PREFIX."categorie_".(empty($this->MAP_CAT_TABLE[$type]) ? $type : $this->MAP_CAT_TABLE[$type]);
		$sql .= " WHERE fk_categorie = ".((int) $this->id)." AND fk_".(empty($this->MAP_CAT_FK[$type]) ? $type : $this->MAP_CAT_FK[$type])." = ".((int) $object_id);

		dol_syslog(get_class($this)."::containsObject", LOG_DEBUG);

		$resql = $this->db->query($sql);
		if ($resql) {
			return $this->db->fetch_object($resql)->nb;
		} else {
			$this->error = $this->db->error();
			return -1;
		}
	}

	/**
	 * Return the list of the categories of a given element (a product, a customer, ...).
	 * Warning, this load/fetch all qualified categories.
	 *
	 * @param	int		$id			Id of element
	 * @param	string	$type		Type of category ('member', 'customer', 'supplier', 'product', 'contact')
	 * @param	string	$sortfield	Sort field
	 * @param	string	$sortorder	Sort order
	 * @param	int		$limit		Limit for list
	 * @param	int		$page		Page number
	 * @return  int<-1,0>|array<int,array{id:int,fk_parent:int,label:string,description:string,color:string,position:int,socid:int,type:string,entity:int,array_options:array<string,mixed>,visible:int,ref_ext:string,multilangs?:array<string,array{label:string,description:string,note?:string}>}> Array of categories, 0 if no cat, -1 on error
	 */
	public function getListForItem($id, $type = 'customer', $sortfield = "s.rowid", $sortorder = 'ASC', $limit = 0, $page = 0)
	{
		$categories = array();

		$type = sanitizeVal($type, 'aZ09');

		$sub_type = $type;
		$subcol_name = "fk_".$type;
		if ($type == "customer") {
			$sub_type = "societe";
			$subcol_name = "fk_soc";
		}
		if ($type == "supplier") {
			$sub_type = "fournisseur";
			$subcol_name = "fk_soc";
		}
		if ($type == "contact") {
			$subcol_name = "fk_socpeople";
		}

		$idoftype = (int) (array_key_exists($type, $this->MAP_ID) ? $this->MAP_ID[$type] : -1);

		$sql = "SELECT s.rowid";
		$sqlfields = $sql; // $sql fields to remove for count total
		$sql .= " FROM ".MAIN_DB_PREFIX."categorie as s, ".MAIN_DB_PREFIX."categorie_".$this->db->sanitize($sub_type)." as sub";
		$sql .= ' WHERE s.entity IN ('.getEntity('category').')';
		$sql .= ' AND s.type = '.((int) $idoftype);
		$sql .= ' AND s.rowid = sub.fk_categorie';
		$sql .= " AND sub.".$this->db->sanitize($subcol_name)." = ".((int) $id);

		$offset = 0;
		$nbtotalofrecords = '';
		if (!getDolGlobalInt('MAIN_DISABLE_FULL_SCANLIST')) {
			$sqlforcount = preg_replace('/^'.preg_quote($sqlfields, '/').'/', 'SELECT COUNT(*) as nbtotalofrecords', $sql);
			$sqlforcount = preg_replace('/GROUP BY .*$/', '', $sqlforcount);

			$resql = $this->db->query($sqlforcount);
			if ($resql) {
				$objforcount = $this->db->fetch_object($resql);
				$nbtotalofrecords = $objforcount->nbtotalofrecords;
			} else {
				dol_print_error($this->db);
			}

			if (($page * $limit) > $nbtotalofrecords) {	// if total resultset is smaller then paging size (filtering), goto and load page 0
				$page = 0;
				$offset = 0;
			}
			$this->db->free($resql);
		}

		$sql .= $this->db->order($sortfield, $sortorder);
		if ($limit) {
			if ($page < 0) {
				$page = 0;
			}
			$offset = $limit * $page;

			$sql .= $this->db->plimit($limit + 1, $offset);
		}

		$result = $this->db->query($sql);
		if ($result) {
			$i = 0;
			$num = $this->db->num_rows($result);
			$min = min($num, ($limit <= 0 ? $num : $limit));
			while ($i < $min) {
				$obj = $this->db->fetch_object($result);
				$category_static = new Categorie($this->db);
				if ($category_static->fetch($obj->rowid)) {
					$categories[$i]['id'] = $category_static->id;
					$categories[$i]['fk_parent'] = $category_static->fk_parent;
					$categories[$i]['label'] = $category_static->label;
					$categories[$i]['description'] = $category_static->description;
					$categories[$i]['color'] = $category_static->color;
					$categories[$i]['position'] = $category_static->position;
					$categories[$i]['socid'] = $category_static->socid;
					$categories[$i]['ref_ext'] = $category_static->ref_ext;
					$categories[$i]['visible'] = $category_static->visible;
					$categories[$i]['type'] = $category_static->type;
					$categories[$i]['entity'] = $category_static->entity;
					$categories[$i]['array_options'] = $category_static->array_options;

					// multilangs
					if (getDolGlobalInt('MAIN_MULTILANGS') && isset($category_static->multilangs)) {
						$categories[$i]['multilangs'] = $category_static->multilangs;
					}
				}
				$i++;
			}
		} else {
			$this->error = $this->db->lasterror();
			return -1;
		}
		if (!count($categories)) {
			return [];
		}

		return $categories;
	}

	// phpcs:disable PEAR.NamingConventions.ValidFunctionName.ScopeNotCamelCaps
	/**
	 * Return direct children ids of a category into an array. Only first level of children.
	 *
	 * @return	Categorie[]|int   Return integer <0 KO, array ok
	 */
	public function get_filles()
	{
		// phpcs:enable
		$sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."categorie";
		$sql .= " WHERE fk_parent = ".((int) $this->id);
		$sql .= " AND entity IN (".getEntity('category').")";

		$res = $this->db->query($sql);
		if ($res) {
			$cats = array();
			while ($rec = $this->db->fetch_array($res)) {
				$cat = new Categorie($this->db);
				$cat->fetch($rec['rowid']);
				$cats[] = $cat;
			}
			return $cats;
		} else {
			dol_print_error($this->db);
			return -1;
		}
	}

	// phpcs:disable PEAR.NamingConventions.ValidFunctionName.ScopeNotCamelCaps
	/**
	 * 	Load the array this->motherof that is array(id_son=>id_parent, ...), so array of all child categories and ID of their parent.
	 *  TODO Add a filter on the type of category.
	 *
	 *	@return		int		Return integer <0 if KO, >0 if OK
	 */
	protected function load_motherof()
	{
		// phpcs:enable
		$this->motherof = array();

		// Load array[child]=parent
		$sql = "SELECT fk_parent as id_parent, rowid as id_son";
		$sql .= " FROM ".MAIN_DB_PREFIX."categorie";
		$sql .= " WHERE fk_parent <> 0";
		$sql .= " AND entity IN (".getEntity('category').")";

		dol_syslog(get_class($this)."::load_motherof", LOG_DEBUG);
		$resql = $this->db->query($sql);
		if ($resql) {
			while ($obj = $this->db->fetch_object($resql)) {
				$this->motherof[$obj->id_son] = $obj->id_parent;
			}
			return 1;
		} else {
			dol_print_error($this->db);
			return -1;
		}
	}

	// phpcs:disable PEAR.NamingConventions.ValidFunctionName.ScopeNotCamelCaps
	/**
	 * Rebuilding the category tree as an array
	 * Return an array of table('id','id_mere',...) sorted to have a human readable tree, with
	 *                id = id of category
	 *                id_mere = id of parent category
	 *                id_children = array of child ids
	 *                label = name of category
	 *                fulllabel = Name with full path for the category
	 *                fullpath = Full path built with the id's
	 *
	 * @param   string              $type               Type of categories ('customer', 'supplier', 'contact', 'product', 'member', ...)
	 * @param   int|string|int[]	$fromid        		Keep only or Exclude (depending on $include parameter) all categories (including the leaf $fromid) into the tree after this id $fromid.
	 *                                                  $fromid can be an :
	 *                                                  - int (id of category)
	 *                                                  - string (categories ids separated by comma)
	 *                                                  - array (list of categories ids)
	 * @param   int<0,1>            $include            [=0] Removed or 1=Keep only the ID into $fromid
	 * @param	string				$forcelangcode		Lang code to force ('fr_FR', 'en_US', ...) or 'none'
	 * @return  int<-1,-1>|array<int,array{rowid:int,id:int,fk_parent:int,label:string,description:string,color:string,position:string,visible:int,ref_ext:string,picto:string,fullpath:string,fulllabel:string,level:?int}>              					Array of categories. this->cats and this->motherof are set, -1 on error
	 */
	public function get_full_arbo($type, $fromid = 0, $include = 0, $forcelangcode = '')
	{
		// phpcs:enable
		global $langs;

		if (!is_numeric($type)) {
			$type = $this->MAP_ID[$type];
		}
		if (is_null($type)) {
			$this->error = 'BadValueForParameterType';
			return -1;
		}

		if (is_string($fromid)) {
			$fromid = explode(',', $fromid);
		} elseif (is_numeric($fromid)) {
			if ($fromid > 0) {
				$fromid = array($fromid);
			} else {
				$fromid = array();
			}
		} elseif (!is_array($fromid)) {
			$fromid = array();
		}

		$this->cats = array();
		$nbcateg = 0;

		// Init this->motherof that is array(id_son=>id_parent, ...)
		$this->load_motherof();

		if ($forcelangcode) {
			$current_lang = $forcelangcode;
		} else {
			$current_lang = $langs->getDefaultLang();
		}

		// Init $this->cats array
		// Note: The DISTINCT reduces pb with old tables with duplicates but should not be used
		$sql = "SELECT DISTINCT c.rowid, c.label, c.ref_ext, c.description, c.color, c.position, c.fk_parent, c.visible";
		if (getDolGlobalInt('MAIN_MULTILANGS') && $current_lang !== 'none') {
			$sql .= ", t.label as label_trans, t.description as description_trans";
		}
		$sql .= " FROM ".MAIN_DB_PREFIX."categorie as c";
		if (getDolGlobalInt('MAIN_MULTILANGS') && $current_lang !== 'none') {
			$sql .= " LEFT JOIN ".MAIN_DB_PREFIX."categorie_lang as t ON t.fk_category = c.rowid AND t.lang = '".$this->db->escape($current_lang)."'";
		}
		$sql .= " WHERE c.entity IN (".getEntity('category').")";
		$sql .= " AND c.type = ".(int) $type;

		dol_syslog(get_class($this)."::get_full_arbo get category list", LOG_DEBUG);

		$resql = $this->db->query($sql);
		if ($resql) {
			$i = 0;
			$nbcateg = $this->db->num_rows($resql);

			while ($obj = $this->db->fetch_object($resql)) {
				$this->cats[$obj->rowid]['rowid'] = $obj->rowid;
				$this->cats[$obj->rowid]['id'] = $obj->rowid;
				$this->cats[$obj->rowid]['fk_parent'] = $obj->fk_parent;
				$this->cats[$obj->rowid]['label'] = !empty($obj->label_trans) ? $obj->label_trans : $obj->label;
				$this->cats[$obj->rowid]['description'] = !empty($obj->description_trans) ? $obj->description_trans : $obj->description;
				$this->cats[$obj->rowid]['color'] = $obj->color;
				$this->cats[$obj->rowid]['position'] = $obj->position;
				$this->cats[$obj->rowid]['visible'] = $obj->visible;
				$this->cats[$obj->rowid]['ref_ext'] = $obj->ref_ext;
				$this->cats[$obj->rowid]['picto'] = 'category';
				// fields are filled with buildPathFromId later
				$this->cats[$obj->rowid]['fullpath'] = '';
				$this->cats[$obj->rowid]['fulllabel'] = '';
				$i++;
			}
		} else {
			dol_print_error($this->db);
			return -1;
		}

		// We add the fullpath property to each elements of first level (no parent exists)
		dol_syslog(get_class($this)."::get_full_arbo call to buildPathFromId", LOG_DEBUG);

		foreach ($this->cats as $key => $val) {
			//print 'key='.$key.'<br>'."\n";
			$this->buildPathFromId($key, $nbcateg); // Process a branch from the root category key (this category has no parent) and adds level to $this->cats items
		}

		// Include or exclude leaf (including $fromid) from tree
		if (count($fromid) > 0) {
			$keyfiltercatid = '('.implode('|', $fromid).')';

			//print "Look to discard category ".$fromid."\n";
			$keyfilter1 = '^'.$keyfiltercatid.'$';
			$keyfilter2 = '_'.$keyfiltercatid.'$';
			$keyfilter3 = '^'.$keyfiltercatid.'_';
			$keyfilter4 = '_'.$keyfiltercatid.'_';
			foreach (array_keys($this->cats) as $key) {
				$fullpath = (string) $this->cats[$key]['fullpath'];
				$test = (preg_match('/'.$keyfilter1.'/', $fullpath) || preg_match('/'.$keyfilter2.'/', $fullpath)
					|| preg_match('/'.$keyfilter3.'/', $fullpath) || preg_match('/'.$keyfilter4.'/', $fullpath));

				if (($test && !$include) || (!$test && $include)) {
					unset($this->cats[$key]);  // @phpstan-ignore-line
				}
			}
		}

		dol_syslog(get_class($this)."::get_full_arbo dol_sort_array", LOG_DEBUG);

		$this->cats = dol_sort_array($this->cats, 'fulllabel', 'asc', 1, 0, 1);		// Sort on full label like "Label 1 >> Sublabel a >> Subsublabel"

		return $this->cats;
	}

	/**
	 *	For category id_categ and its children available in this->cats, define property fullpath and fulllabel.
	 *  It is called by get_full_arbo()
	 *  This function is a memory scan only from $this->cats and $this->motherof, no database access must be done here.
	 *
	 * 	@param		int		$id_categ		id_categ entry to update
	 * 	@param		int		$protection		Deep counter to avoid infinite loop
	 *	@return		int<-1,1>				Return integer <0 if KO, >0 if OK
	 *  @see get_full_arbo()
	 */
	private function buildPathFromId($id_categ, $protection = 1000)
	{
		//dol_syslog(get_class($this)."::buildPathFromId id_categ=".$id_categ." protection=".$protection, LOG_DEBUG);

		if (!empty($this->cats[$id_categ]['fullpath'])) {
			// Already defined
			dol_syslog(get_class($this)."::buildPathFromId fullpath and fulllabel already defined", LOG_WARNING);
			return -1;
		}

		// $this->cats[$id_categ] is supposed to be already an array. We just want to complete it with property fullpath and fulllabel

		// Define fullpath and fulllabel
		$this->cats[$id_categ]['fullpath'] = '_'.$id_categ;
		$this->cats[$id_categ]['fulllabel'] = $this->cats[$id_categ]['label'];
		$i = 0;
		$cursor_categ = $id_categ;
		//print 'Work for id_categ='.$id_categ.'<br>'."\n";
		while ((empty($protection) || $i < $protection) && !empty($this->motherof[$cursor_categ])) {
			//print '&nbsp; cursor_categ='.$cursor_categ.' i='.$i.' '.$this->motherof[$cursor_categ].'<br>'."\n";
			$this->cats[$id_categ]['fullpath'] = '_'.$this->motherof[$cursor_categ].$this->cats[$id_categ]['fullpath'];
			$this->cats[$id_categ]['fulllabel'] = (empty($this->cats[$this->motherof[$cursor_categ]]) ? 'NotFound' : $this->cats[$this->motherof[$cursor_categ]]['label']).' >> '.$this->cats[$id_categ]['fulllabel'];
			//print '&nbsp; Result for id_categ='.$id_categ.' : '.$this->cats[$id_categ]['fullpath'].' '.$this->cats[$id_categ]['fulllabel'].'<br>'."\n";
			$i++;
			$cursor_categ = $this->motherof[$cursor_categ];
		}
		//print 'Result for id_categ='.$id_categ.' : '.$this->cats[$id_categ]['fullpath'].'<br>'."\n";

		// We count number of _ to have level
		$nbunderscore = substr_count($this->cats[$id_categ]['fullpath'], '_');
		$this->cats[$id_categ]['level'] = ($nbunderscore ? $nbunderscore : null);

		return 1;
	}


	// phpcs:disable PEAR.NamingConventions.ValidFunctionName.ScopeNotCamelCaps
	/**
	 * 	Returns all categories
	 *
	 *	@param	?int		$type		Type of category (0, 1, ...)
	 *	@param	boolean		$parent		Just parent categories if true
	 *	@return	array<int,Categorie>|int<-1,-1>	Table of Object Category, -1 on error
	 */
	public function get_all_categories($type = null, $parent = false)
	{
		// phpcs:enable
		if (!is_numeric($type) && !is_null($type)) {
			$type = $this->MAP_ID[$type];
		}

		$sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."categorie";
		$sql .= " WHERE entity IN (".getEntity('category').")";
		if (!is_null($type)) {
			$sql .= " AND type = ".(int) $type;
		}
		if ($parent) {
			$sql .= " AND fk_parent = 0";
		}

		$res = $this->db->query($sql);
		if ($res) {
			$cats = array();
			while ($rec = $this->db->fetch_array($res)) {
				$cat = new Categorie($this->db);
				$cat->fetch($rec['rowid']);
				$cats[$rec['rowid']] = $cat;
			}
			return $cats;
		} else {
			dol_print_error($this->db);
			return -1;
		}
	}

	// phpcs:disable PEAR.NamingConventions.ValidFunctionName.ScopeNotCamelCaps
	/**
	 *	Returns the first level categories (which are not child)
	 *
	 *	@param	?int		$type		Type of category (0, 1, ...)
	 *	@return	array<int,Categorie>|int<-1,-1>	Table of Object Category, -1 on error
	 */
	public function get_main_categories($type = null)
	{
		// phpcs:enable
		return $this->get_all_categories($type, true);
	}

	// phpcs:disable PEAR.NamingConventions.ValidFunctionName.ScopeNotCamelCaps
	/**
	 * 	Check if a category with same label already exists for this cat's parent or root and for this cat's type
	 *
	 * 	@return		integer		1 if record already exist, 0 otherwise, -1 if error
	 */
	public function already_exists()
	{
		// phpcs:enable
		$type = $this->type;

		if (!is_numeric($type)) {
			$type = $this->MAP_ID[$type];
		}

		/* We have to select any rowid from llx_categorie which category's mother and label
		 * are equals to those of the calling category
		 */
		$sql = "SELECT c.rowid";
		$sql .= " FROM ".MAIN_DB_PREFIX."categorie as c ";
		$sql .= " WHERE c.entity IN (".getEntity('category').")";
		$sql .= " AND c.type = ".((int) $type);
		$sql .= " AND c.fk_parent = ".((int) $this->fk_parent);
		$sql .= " AND c.label = '".$this->db->escape($this->label)."'";

		dol_syslog(get_class($this)."::already_exists", LOG_DEBUG);

		$resql = $this->db->query($sql);
		if ($resql) {
			if ($this->db->num_rows($resql) > 0) {						// Checking for empty resql
				$obj = $this->db->fetch_object($resql);
				/* If object called create, obj cannot have is id.
				 * If object called update, he mustn't have the same label as an other category for this mother.
				 * So if the result has the same id, update is not for label, and if result has an other one, update may be for label.
				 */
				if (!empty($obj) && $obj->rowid > 0 && $obj->rowid != $this->id) {
					dol_syslog(get_class($this)."::already_exists category with name=".$this->label." and parent ".$this->fk_parent." exists: rowid=".$obj->rowid." current_id=".$this->id, LOG_DEBUG);
					return 1;
				}
			}
			dol_syslog(get_class($this)."::already_exists no category with same name=".$this->label." and same parent ".$this->fk_parent." than category id=".$this->id, LOG_DEBUG);
			return 0;
		} else {
			$this->error = $this->db->error();
			return -1;
		}
	}


	// phpcs:disable PEAR.NamingConventions.ValidFunctionName.ScopeNotCamelCaps
	/**
	 * Returns the path of the category, with the names of the categories
	 * separated by $sep (" >> " by default)
	 *
	 * @param	string	$sep	     Separator
	 * @param	string	$url	     Url ('', 'none' or 'urltouse')
	 * @param   int     $nocolor     0
	 * @param	int		$addpicto	 Add picto into link
	 * @return	string[]
	 */
	public function print_all_ways($sep = '&gt;&gt;', $url = '', $nocolor = 0, $addpicto = 0)
	{
		// phpcs:enable
		$ways = array();

		$all_ways = $this->get_all_ways(); // Load array of categories to reach this->id

		foreach ($all_ways as $way) {	// It seems we always have 1 entry in this array.
			$w = array();
			$i = 0;
			$forced_color = '';
			foreach ($way as $cat) {	// Loop on each successive categories to reach the target of current category
				$i++;

				if (empty($nocolor)) {
					$forced_color = 'colortoreplace';
					if ($i == count($way)) {	// Last category in hierarchy
						// Check contrast with background and correct text color
						$forced_color = 'categtextwhite'; // We want color white because the getNomUrl of a tag is always called inside a dark background like '<span color="bbb"></span>' to show it as a tag. TODO Add this in param to force when called outside of span.
						if ($cat->color) {
							if (colorIsLight($cat->color)) {
								$forced_color = 'categtextblack';
							}
						}
					}
				}

				if ($url == '') {
					$link = '<a href="'.DOL_URL_ROOT.'/categories/viewcat.php?id='.((int) $cat->id).'&type='.urlencode($cat->type).'" class="'.($i < count($way) ? 'small ' : '').$forced_color.'">';
					$linkend = '</a>';
					$w[] = $link.(($addpicto && $i == 1) ? img_object('', 'category', 'class="paddingright"') : '').$cat->label.$linkend;
				} elseif ($url == 'none') {
					$link = '<span class="valignmiddle '.($i < count($way) ? 'small ' : '').$forced_color.'">';
					$linkend = '</span>';
					$w[] = $link.(($addpicto && $i == 1) ? img_object('', 'category', 'class="paddingright"') : '').$cat->label.$linkend;
				} else {
					$w[] = '<a class="valignmiddle '.($i < count($way) ? 'small ' : '').$forced_color.'" href="'.DOL_URL_ROOT.'/'.$url.'?catid='.((int) $cat->id).'">'.($addpicto ? img_object('', 'category') : '').$cat->label.'</a>';
				}
			}
			$newcategwithpath = preg_replace('/colortoreplace/', $forced_color, implode('<span class="inline-block valignmiddle paddingleft paddingright '.$forced_color.'">'.$sep.'</span>', $w));

			$ways[] = $newcategwithpath;
		}

		return $ways;
	}


	// phpcs:disable PEAR.NamingConventions.ValidFunctionName.ScopeNotCamelCaps
	/**
	 *	Returns an array containing the list of parent categories
	 *  Note: A category can only have one parent but this method return an array to work the same way the get_filles is working.
	 *
	 *	@return	int|Categorie[] Return integer <0 KO, array OK
	 */
	public function get_meres()
	{
		// phpcs:enable
		$parents = array();

		$sql = "SELECT fk_parent FROM ".MAIN_DB_PREFIX."categorie";
		$sql .= " WHERE rowid = ".((int) $this->id);

		$res = $this->db->query($sql);

		if ($res) {
			while ($rec = $this->db->fetch_array($res)) {
				if ($rec['fk_parent'] > 0) {
					$cat = new Categorie($this->db);
					$cat->fetch($rec['fk_parent']);
					$parents[] = $cat;
				}
			}
			return $parents;
		} else {
			dol_print_error($this->db);
			return -1;
		}
	}

	// phpcs:disable PEAR.NamingConventions.ValidFunctionName.ScopeNotCamelCaps
	/**
	 * 	Returns in a array all possible paths to go to the category
	 * 	starting with the major categories represented by Tables of categories
	 *
	 *	@return	Categorie[][]
	 */
	public function get_all_ways()
	{
		// phpcs:enable
		$ways = array();

		$parents = $this->get_meres();
		if (is_array($parents)) {
			foreach ($parents as $parent) {
				$all_ways = $parent->get_all_ways();	// recursivity. TODO Add a protection for infinite loop
				foreach ($all_ways as $way) {
					$w = $way;
					$w[] = $this;
					$ways[] = $w;
				}
			}
		}

		if (count($ways) == 0) {
			$ways[0][0] = $this;
		}

		return $ways;
	}

	/**
	 * Return list of categories (object instances or labels) linked to a given object having id $id and type $type.
	 * Should be named getListOfCategForObject.
	 * Try to use it only with parameter $mode = 'id' or 'label'.
	 *
	 * @param   int    		$id                 Id of element
	 * @param   string|int	$type               Type of category ('customer', 'supplier', 'contact', 'product', 'member') or (0, 1, 2, ...)
	 * @param   string 		$mode               'id'=Get array of category IDs, 'label'=Get array of category labels, 'object'=Get array of fetched category instances
	 * @return  Categorie[]|int[]|string[]|int  Array of category objects, labels or IDs or < 0 if KO
	 */
	public function containing($id, $type, $mode = 'object')
	{
		$cats = array();

		if (is_numeric($type)) {
			$type = array_search($type, $this->MAP_ID);
		}

		if ($type === Categorie::TYPE_BANK_LINE) {   // TODO Remove this after migration of llx_category_bankline into llx_categorie_bankline
			// Load bank categories
			$sql = "SELECT c.label, c.rowid";
			$sql .= " FROM ".MAIN_DB_PREFIX."category_bankline as a, ".MAIN_DB_PREFIX."categorie as c";
			$sql .= " WHERE a.lineid=".((int) $id)." AND a.fk_categ = c.rowid";
			$sql .= " AND c.entity IN (".getEntity('category').")";
			$sql .= " ORDER BY c.label";

			$res = $this->db->query($sql);
			if ($res) {
				while ($obj = $this->db->fetch_object($res)) {
					if ($mode == 'id') {
						$cats[] = $obj->rowid;
					} elseif ($mode == 'label') {
						$cats[] = $obj->label;
					} else {
						$cat = new Categorie($this->db);
						$cat->id = $obj->rowid;
						$cat->label = $obj->label;
						$cats[] = $cat;
					}
				}
			} else {
				dol_print_error($this->db);
				return -1;
			}
		} else {
			$sql = "SELECT ct.fk_categorie, c.label, c.rowid";
			$sql .= " FROM ".MAIN_DB_PREFIX."categorie_".(empty($this->MAP_CAT_TABLE[$type]) ? $type : $this->MAP_CAT_TABLE[$type])." as ct, ".MAIN_DB_PREFIX."categorie as c";
			$sql .= " WHERE ct.fk_categorie = c.rowid AND ct.fk_".(empty($this->MAP_CAT_FK[$type]) ? $type : $this->MAP_CAT_FK[$type])." = ".(int) $id;
			// This seems useless because the table already contains id of category of 1 unique type. So commented.
			// So now it works also with external added categories.
			//$sql .= " AND c.type = ".((int) $this->MAP_ID[$type]);
			$sql .= " AND c.entity IN (".getEntity('category').")";

			$res = $this->db->query($sql);
			if ($res) {
				while ($obj = $this->db->fetch_object($res)) {
					if ($mode == 'id') {
						$cats[] = $obj->rowid;
					} elseif ($mode == 'label') {
						$cats[] = $obj->label;
					} else {
						$cat = new Categorie($this->db);
						$cat->fetch($obj->fk_categorie);
						$cats[] = $cat;
					}
				}
			} else {
				dol_print_error($this->db);
				return -1;
			}
		}

		return $cats;
	}

	/**
	 * 	Returns categories whose id or name matches.
	 * 	It add wildcards in the name unless $exact = true
	 *
	 * 	@param		int			$id			Id
	 * 	@param		string		$nom		Name
	 * 	@param		string		$type		Type of category ('member', 'customer', 'supplier', 'product', 'contact'). Old mode (0, 1, 2, ...) is deprecated.
	 * 	@param		boolean		$exact		Exact string search (true/false)
	 * 	@param		boolean		$case		Case sensitive (true/false)
	 * 	@return		Categorie[]|int			Array of Categorie, -1 if error
	 */
	public function rechercher($id, $nom, $type, $exact = false, $case = false)
	{
		// Deprecation warning
		if (is_numeric($type)) {
			dol_syslog(__METHOD__.': using numeric types is deprecated.', LOG_WARNING);
		}

		$cats = array();

		// For backward compatibility
		if (is_numeric($type)) {
			// We want to reverse lookup
			$map_type = array_flip($this->MAP_ID);
			$type = $map_type[$type];
			dol_syslog(get_class($this)."::rechercher(): numeric types are deprecated, please use string instead", LOG_WARNING);
		}

		// Generation requete recherche
		$sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."categorie";
		$sql .= " WHERE type = ".((int) $this->MAP_ID[$type]);
		$sql .= " AND entity IN (".getEntity('category').")";
		if ($nom) {
			if (!$exact) {
				$nom = '%'.$this->db->escape(str_replace('*', '%', $nom)).'%';
			}
			if (!$case) {
				$sql .= " AND label LIKE '".$this->db->escape($nom)."'";
			} else {
				$sql .= " AND label LIKE BINARY '".$this->db->escape($nom)."'";
			}
		}
		if ($id) {
			$sql .= " AND rowid = ".((int) $id);
		}

		$res = $this->db->query($sql);
		if ($res) {
			while ($rec = $this->db->fetch_array($res)) {
				$cat = new Categorie($this->db);
				$cat->fetch($rec['rowid']);
				$cats[] = $cat;
			}

			return $cats;
		} else {
			$this->error = $this->db->error().' sql='.$sql;
			return -1;
		}
	}

	/**
	 *  Return if at least one photo is available
	 *
	 * @param  string $sdir Directory to scan
	 * @return boolean                 True if at least one photo is available, False if not
	 */
	public function isAnyPhotoAvailable($sdir)
	{
		include_once DOL_DOCUMENT_ROOT . '/core/lib/files.lib.php';
		include_once DOL_DOCUMENT_ROOT . '/core/lib/images.lib.php';

		$sdir .= '/' . get_exdir($this->id, 2, 0, 0, $this, 'category') . $this->id . "/photos/";

		$dir_osencoded = dol_osencode($sdir);
		if (file_exists($dir_osencoded)) {
			$handle = opendir($dir_osencoded);
			if (is_resource($handle)) {
				while (($file = readdir($handle)) !== false) {
					if (!utf8_check($file)) {
						$file = mb_convert_encoding($file, 'UTF-8', 'ISO-8859-1'); // To be sure data is stored in UTF8 in memory
					}
					if (dol_is_file($sdir . $file) && image_format_supported($file) >= 0) {
						return true;
					}
				}
			}
		}
		return false;
	}

	/**
	 * getTooltipContentArray
	 * @param array<string,mixed> $params params to construct tooltip data
	 * @since v18
	 * @return array{picto?:string,ref?:string,refsupplier?:string,label?:string,date?:string,date_echeance?:string,amountht?:string,total_ht?:string,totaltva?:string,amountlt1?:string,amountlt2?:string,amountrevenustamp?:string,totalttc?:string}|array{optimize:string}
	 */
	public function getTooltipContentArray($params)
	{
		global $langs;

		$langs->load('categories');

		$datas = [];

		$datas['label'] = $langs->trans("ShowCategory").': '.($this->ref ? $this->ref : $this->label);

		return $datas;
	}

	/**
	 *	Return name and link of category (with picto)
	 *  Use ->id, ->ref, ->label, ->color
	 *
	 *	@param		int		$withpicto				0=No picto, 1=Include picto into link, 2=Only picto
	 *	@param		string	$option					On what the link point to ('nolink', ...)
	 * 	@param		int		$maxlength				Max length of text
	 *  @param		string	$moreparam				More param on URL link
	 *  @param  	int     $notooltip      		1=Disable tooltip
	 *  @param  	string  $morecss                Add more css on link
	 *  @param  	int     $save_lastsearch_value	-1=Auto, 0=No save of lastsearch_values when clicking, 1=Save lastsearch_values whenclicking
	 *	@return		string							String with URL
	 */
	public function getNomUrl($withpicto = 0, $option = '', $maxlength = 0, $moreparam = '', $notooltip = 0, $morecss = '', $save_lastsearch_value = 0)
	{
		global $conf, $langs, $hookmanager;

		if (!empty($conf->dol_no_mouse_hover)) {
			$notooltip = 1; // Force disable tooltips
		}

		$result = '';
		$params = [
			'id' => $this->id,
			'objecttype' => $this->element,
			'option' => $option,
		];
		$classfortooltip = 'classfortooltip';
		$dataparams = '';
		if (getDolGlobalInt('MAIN_ENABLE_AJAX_TOOLTIP')) {
			$classfortooltip = 'classforajaxtooltip';
			$dataparams = ' data-params="'.dol_escape_htmltag(json_encode($params)).'"';
			$label = '';
		} else {
			$label = implode($this->getTooltipContentArray($params));
		}

		$url = DOL_URL_ROOT.'/categories/viewcat.php?id='.$this->id.'&type='.$this->type.$moreparam.'&backtopage='.urlencode($_SERVER['PHP_SELF'].($moreparam ? '?'.$moreparam : ''));

		if ($option !== 'nolink') {
			// Add param to save lastsearch_values or not
			$add_save_lastsearch_values = ($save_lastsearch_value == 1 ? 1 : 0);
			if ($save_lastsearch_value == -1 && isset($_SERVER["PHP_SELF"]) && preg_match('/list\.php/', $_SERVER["PHP_SELF"])) {
				$add_save_lastsearch_values = 1;
			}
			if (/* $url && */ $add_save_lastsearch_values) {
				$url .= '&save_lastsearch_values=1';
			}
		}

		// Check contrast with background and correct text color
		$forced_color = 'categtextwhite'; // We want color white because the getNomUrl of a tag is always called inside a dark background like '<span color="bbb"></span>' to show it as a tag. TODO Add this in param to force when called outside of span.
		if ($this->color) {
			if (colorIsLight($this->color)) {	// If color is light, we force color to dark
				$forced_color = 'categtextblack';
			}
		}

		$linkclose = '';
		if (empty($notooltip)) {
			if (getDolGlobalInt('MAIN_OPTIMIZEFORTEXTBROWSER')) {
				$label = $langs->trans("ShowMyObject");
				$linkclose .= ' alt="'.dolPrintHTMLForAttribute($label).'"';
			}
			$linkclose .= ($label ? ' title="'.dolPrintHTMLForAttribute($label).'"' : ' title="tocomplete"');
			$linkclose .= $dataparams.' class="'.$classfortooltip.' '.$forced_color.($morecss ? ' '.$morecss : '').'"';
		} else {
			$linkclose = ' class="'.$forced_color.($morecss ? ' '.$morecss : '').'"';
		}

		if ($option == 'nolink' /* || empty($url) */) {
			$linkstart = '<span';
		} else {
			$linkstart = '<a href="'.$url.'"';
		}
		$linkstart .= $linkclose.'>';
		if ($option == 'nolink' /* || empty($url) */) {
			$linkend = '</span>';
		} else {
			$linkend = '</a>';
		}

		$result .= $linkstart;

		if ($withpicto) {
			$result .= img_object(($notooltip ? '' : $label), ($this->picto ? $this->picto : 'generic'), ($notooltip ? (($withpicto != 2) ? 'class="paddingright"' : '') : 'class="'.(($withpicto != 2) ? 'paddingright ' : '').'"'), 0, 0, $notooltip ? 0 : 1);
		}

		if ($withpicto != 2) {
			$result .= dol_trunc(($this->ref ? $this->ref : $this->label), $maxlength);
		}

		$result .= $linkend;

		global $action;
		$hookmanager->initHooks(array($this->element . 'dao'));
		$parameters = array('id' => $this->id, 'getnomurl' => &$result);
		$reshook = $hookmanager->executeHooks('getNomUrl', $parameters, $this, $action); // Note that $action and $object may have been modified by some hooks
		if ($reshook > 0) {
			$result = $hookmanager->resPrint;
		} else {
			$result .= $hookmanager->resPrint;
		}
		return $result;
	}


	// phpcs:disable PEAR.NamingConventions.ValidFunctionName.ScopeNotCamelCaps
	/**
	 *  Add the image uploaded as $file to the directory $sdir/<category>-<id>/photos/
	 *
	 *  @param	string								$sdir       Root destination directory
	 *  @param	array{name:string,tmp_name:string}	$file		Uploaded file name
	 *	@return	void
	 */
	public function add_photo($sdir, $file)
	{
		// phpcs:enable
		require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php';

		$dir = $sdir.'/'.get_exdir($this->id, 2, 0, 0, $this, 'category').$this->id."/";
		$dir .= "photos/";

		if (!file_exists($dir)) {
			dol_mkdir($dir);
		}

		if (file_exists($dir)) {
			if (is_array($file['name'])) {
				$nbfile = count($file['name']);
				for ($i = 0; $i < $nbfile; $i++) {
					$originImage = $dir.$file['name'][$i];

					// Cree fichier en taille origine
					dol_move_uploaded_file($file['tmp_name'][$i], $originImage, 1, 0, 0);

					if (file_exists($originImage)) {
						// Create thumbs
						$this->addThumbs($originImage);
					}
				}
			} else {
				$originImage = $dir.$file['name'];

				// Cree fichier en taille origine
				dol_move_uploaded_file($file['tmp_name'], $originImage, 1, 0, 0);

				if (file_exists($originImage)) {
					// Create thumbs
					$this->addThumbs($originImage);
				}
			}
		}
	}

	// phpcs:disable PEAR.NamingConventions.ValidFunctionName.ScopeNotCamelCaps
	/**
	 *    Return an array with all photos inside the directory
	 *
	 *    @param	string	$dir        Dir to scan
	 *    @param	int		$nbmax      Nombre maximum de photos (0=pas de max)
	 *    @return	array<int,array{photo:string,photo_vignette:string}>	Table with images
	 */
	public function liste_photos($dir, $nbmax = 0)
	{
		// phpcs:enable
		include_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php';

		$nbphoto = 0;
		$tabobj = array();

		$dirthumb = $dir.'thumbs/';

		if (file_exists($dir)) {
			$handle = opendir($dir);
			if (is_resource($handle)) {
				while (($file = readdir($handle)) !== false) {
					if (dol_is_file($dir.$file) && preg_match('/(\.jpeg|\.jpg|\.bmp|\.gif|\.png|\.tiff)$/i', $dir.$file)) {
						$nbphoto++;
						$photo = $file;

						// On determine nom du fichier vignette
						$photo_vignette = '';
						$regs = array();
						if (preg_match('/(\.jpeg|\.jpg|\.bmp|\.gif|\.png|\.tiff)$/i', $photo, $regs)) {
							$photo_vignette = preg_replace('/'.$regs[0].'/i', '', $photo).'_small'.$regs[0];
						}

						// Object
						$obj = array();
						$obj['photo'] = $photo;
						if ($photo_vignette && is_file($dirthumb.$photo_vignette)) {
							$obj['photo_vignette'] = 'thumbs/'.$photo_vignette;
						} else {
							$obj['photo_vignette'] = "";
						}

						$tabobj[$nbphoto - 1] = $obj;

						// On continue ou on arrete de boucler
						if ($nbmax && $nbphoto >= $nbmax) {
							break;
						}
					}
				}

				closedir($handle);
			}
		}

		return $tabobj;
	}

	// phpcs:disable PEAR.NamingConventions.ValidFunctionName.ScopeNotCamelCaps
	/**
	 *    Efface la photo de la categorie et sa vignette
	 *
	 *    @param	string		$file		Path to file
	 *    @return	void
	 */
	public function delete_photo($file)
	{
		// phpcs:enable
		require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php';

		$dir = dirname($file).'/'; // Chemin du dossier contenant l'image d'origine
		$dirthumb = $dir.'/thumbs/'; // Chemin du dossier contenant la vignette
		$filename = preg_replace('/'.preg_quote($dir, '/').'/i', '', $file); // Nom du fichier

		// On efface l'image d'origine
		dol_delete_file($file, 0); // do not use disableglob, ecmfiles will not be deleted

		// Si elle existe, on efface la vignette
		$regs = array();
		if (preg_match('/(\.jpeg|\.jpg|\.bmp|\.gif|\.png|\.tiff)$/i', $filename, $regs)) {
			$photo_vignette = preg_replace('/'.$regs[0].'/i', '', $filename).'_small'.$regs[0];
			if (file_exists($dirthumb.$photo_vignette)) {
				dol_delete_file($dirthumb.$photo_vignette, 1);
			}
		}
	}

	// phpcs:disable PEAR.NamingConventions.ValidFunctionName.ScopeNotCamelCaps
	/**
	 *  Load size of image file
	 *
	 *  @param    	string	$file        Path to file
	 *  @return		void
	 */
	public function get_image_size($file)
	{
		// phpcs:enable
		$infoImg = getimagesize($file); // Recuperation des infos de l'image
		$this->imgWidth = $infoImg[0]; // Largeur de l'image
		$this->imgHeight = $infoImg[1]; // Hauteur de l'image
	}

	/**
	 *	Create or Update translations of categories labels
	 *
	 *	@param	User	$user		Object user
	 *  @param	int		$notrigger	1=Does not execute triggers, 0= execute triggers
	 *
	 *	@return		int		Return integer <0 if KO, >0 if OK
	 */
	public function setMultiLangs(User $user, $notrigger = 0)
	{
		global $langs;

		$langs_available = $langs->get_available_languages();
		$current_lang = $langs->getDefaultLang();

		foreach ($langs_available as $key => $value) {
			$sql = "SELECT rowid";
			$sql .= " FROM ".MAIN_DB_PREFIX."categorie_lang";
			$sql .= " WHERE fk_category=".((int) $this->id);
			$sql .= " AND lang = '".$this->db->escape($key)."'";

			$result = $this->db->query($sql);

			if ($key == $current_lang) {
				$sql2 = '';
				if ($this->db->num_rows($result)) { // if no line in database
					$sql2 = "UPDATE ".MAIN_DB_PREFIX."categorie_lang";
					$sql2 .= " SET label = '".$this->db->escape($this->label)."',";
					$sql2 .= " description = '".$this->db->escape($this->description)."'";
					$sql2 .= " WHERE fk_category = ".((int) $this->id)." AND lang = '".$this->db->escape($key)."'";
				} elseif (isset($this->multilangs[$key])) {
					$sql2 = "INSERT INTO ".MAIN_DB_PREFIX."categorie_lang (fk_category, lang, label, description)";
					$sql2 .= " VALUES(".((int) $this->id).", '".$this->db->escape($key)."', '".$this->db->escape($this->label)."'";
					$sql2 .= ", '".$this->db->escape($this->multilangs[$key]["description"])."')";
				}
				dol_syslog(get_class($this).'::setMultiLangs', LOG_DEBUG);
				if ($sql2 && !$this->db->query($sql2)) {
					$this->error = $this->db->lasterror();
					return -1;
				}
			} elseif (isset($this->multilangs[$key])) {
				if ($this->db->num_rows($result)) { // if no line in database
					$sql2 = "UPDATE ".MAIN_DB_PREFIX."categorie_lang";
					$sql2 .= " SET label='".$this->db->escape($this->multilangs[$key]["label"])."',";
					$sql2 .= " description='".$this->db->escape($this->multilangs[$key]["description"])."'";
					$sql2 .= " WHERE fk_category=".((int) $this->id)." AND lang='".$this->db->escape($key)."'";
				} else {
					$sql2 = "INSERT INTO ".MAIN_DB_PREFIX."categorie_lang (fk_category, lang, label, description)";
					$sql2 .= " VALUES(".((int) $this->id).", '".$this->db->escape($key)."', '".$this->db->escape($this->multilangs[$key]["label"])."'";
					$sql2 .= ",'".$this->db->escape($this->multilangs[$key]["description"])."')";
				}

				// on ne sauvegarde pas des champs vides
				if ($this->multilangs[$key]["label"] || $this->multilangs[$key]["description"] || $this->multilangs[$key]["note"]) {
					dol_syslog(get_class($this).'::setMultiLangs', LOG_DEBUG);
				}
				if (!$this->db->query($sql2)) {
					$this->error = $this->db->lasterror();
					return -1;
				}
			}
		}

		// Call trigger
		if (!$notrigger) {
			$result = $this->call_trigger('CATEGORY_SET_MULTILANGS', $user);
			if ($result < 0) {
				$this->error = $this->db->lasterror();
				return -1;
			}
		}
		// End call triggers

		return 1;
	}

	/**
	 * Delete a language for this category
	 *
	 * @param string $langtodelete Language code to delete
	 * @param User   $user         Object user making delete
	 *
	 * @return int                            Return integer <0 if KO, >0 if OK
	 */
	public function delMultiLangs($langtodelete, $user)
	{
		$sql = "DELETE FROM ".$this->db->prefix()."categorie_lang";
		$sql .= " WHERE fk_category = ".((int) $this->id)." AND lang = '".$this->db->escape($langtodelete)."'";

		dol_syslog(get_class($this).'::delMultiLangs', LOG_DEBUG);
		$result = $this->db->query($sql);
		if ($result) {
			// Call trigger
			$result = $this->call_trigger('CATEGORY_DEL_MULTILANGS', $user);
			if ($result < 0) {
				$this->error = $this->db->lasterror();
				dol_syslog(get_class($this).'::delMultiLangs error='.$this->error, LOG_ERR);
				return -1;
			}
			// End call triggers
			return 1;
		} else {
			$this->error = $this->db->lasterror();
			dol_syslog(get_class($this).'::delMultiLangs error='.$this->error, LOG_ERR);
			return -1;
		}
	}

	/**
	 *	Load array this->multilangs
	 *
	 *	@return		int		Return integer <0 if KO, >0 if OK
	 */
	public function getMultiLangs()
	{
		global $langs;

		$current_lang = $langs->getDefaultLang();

		$sql = "SELECT lang, label, description";
		$sql .= " FROM ".MAIN_DB_PREFIX."categorie_lang";
		$sql .= " WHERE fk_category=".((int) $this->id);

		$result = $this->db->query($sql);
		if ($result) {
			while ($obj = $this->db->fetch_object($result)) {
				//print 'lang='.$obj->lang.' current='.$current_lang.'<br>';
				if ($obj->lang == $current_lang) { // si on a les traduct. dans la langue courante on les charge en infos principales.
					$this->label = $obj->label;
					$this->description = $obj->description;
				}
				$this->multilangs[$obj->lang]["label"] = $obj->label;
				$this->multilangs[$obj->lang]["description"] = $obj->description;
			}
			return 1;
		} else {
			$this->error = $langs->trans("Error")." : ".$this->db->error()." - ".$sql;
			return -1;
		}
	}

	/**
	 *	Return label of contact status
	 *
	 *	@param      int		$mode       0=Long label, 1=Short label, 2=Picto + Short label, 3=Picto, 4=Picto + Long label, 5=Short label + Picto, 6=Long label + Picto
	 * 	@return 	string				Label of contact status
	 */
	public function getLibStatut($mode)
	{
		return '';
	}


	/**
	 *  Initialise an instance with random values.
	 *  Used to build previews or test instances.
	 *	id must be 0 if object instance is a specimen.
	 *
	 *  @return	int
	 */
	public function initAsSpecimen()
	{
		dol_syslog(get_class($this)."::initAsSpecimen");

		// Initialise parameters
		$this->id = 0;
		$this->fk_parent = 0;
		$this->label = 'SPECIMEN';
		$this->specimen = 1;
		$this->description = 'This is a description';
		$this->socid = 1;
		$this->type = self::TYPE_PRODUCT;

		return 1;
	}

	/**
	 * Function used to replace a thirdparty id with another one.
	 *
	 * @param 	DoliDB 	$dbs 		Database handler, because function is static we name it $dbs not $db to avoid breaking coding test
	 * @param 	int 	$origin_id 	Old thirdparty id
	 * @param 	int 	$dest_id 	New thirdparty id
	 * @return 	bool
	 */
	public static function replaceThirdparty(DoliDB $dbs, $origin_id, $dest_id)
	{
		$tables = array(
			'categorie_societe'
		);

		return CommonObject::commonReplaceThirdparty($dbs, $origin_id, $dest_id, $tables, 1);
	}

	/**
	 * Return the additional SQL JOIN query for filtering a list by a category
	 *
	 * @param string	$type			The category type (e.g Categorie::TYPE_WAREHOUSE)
	 * @param string	$rowIdName		The name of the row id inside the whole sql query (e.g. "e.rowid")
	 * @return string					A additional SQL JOIN query
	 * @deprecated	search on some categories must be done using a WHERE EXISTS or NOT EXISTS and not a LEFT JOIN. @TODO Replace with getWhereQuery($type, $searchCategoryList)
	 */
	public static function getFilterJoinQuery($type, $rowIdName)
	{
		if ($type == 'bank_account') {
			$type = 'account';
		}

		return " LEFT JOIN ".MAIN_DB_PREFIX."categorie_".$type." as cp ON ".$rowIdName." = cp.fk_".$type;
	}

	/**
	 * Return the additional SQL SELECT query for filtering a list by a category
	 *
	 * @param string	$type			The category type (e.g Categorie::TYPE_WAREHOUSE)
	 * @param string	$rowIdName		The name of the row id inside the whole sql query (e.g. "e.rowid")
	 * @param string[]	$searchList		A list with the selected categories
	 * @return string					A additional SQL SELECT query
	 * @deprecated	search on some categories must be done using a WHERE EXISTS or NOT EXISTS and not a LEFT JOIN
	 */
	public static function getFilterSelectQuery($type, $rowIdName, $searchList)
	{
		if ($type == 'bank_account') {
			$type = 'account';
		}
		if ($type == 'customer') {
			$type = 'societe';
		}
		if ($type == 'supplier') {
			$type = 'fournisseur';
		}

		if (empty($searchList) && !is_array($searchList)) {
			return "";
		}

		$searchCategorySqlList = array();
		foreach ($searchList as $searchCategory) {
			if (intval($searchCategory) == -2) {
				$searchCategorySqlList[] = " cp.fk_categorie IS NULL";
			} elseif (intval($searchCategory) > 0) {
				$searchCategorySqlList[] = " ".$rowIdName." IN (SELECT fk_".$type." FROM ".MAIN_DB_PREFIX."categorie_".$type." WHERE fk_categorie = ".((int) $searchCategory).")";
			}
		}

		if (!empty($searchCategorySqlList)) {
			return " AND (".implode(' AND ', $searchCategorySqlList).")";
		} else {
			return "";
		}
	}

	/**
	 *      Count all categories
	 *
	 *      @return int                             Number of categories, -1 on error
	 */
	public function countNbOfCategories()
	{
		dol_syslog(get_class($this)."::count_all_categories", LOG_DEBUG);
		$sql = "SELECT COUNT(rowid) FROM ".MAIN_DB_PREFIX."categorie";
		$sql .= " WHERE entity IN (".getEntity('category').")";

		$res = $this->db->query($sql);
		if ($res) {
			$obj = $this->db->fetch_object($res);
			return $obj->count;
		} else {
			dol_print_error($this->db);
			return -1;
		}
	}
}
