<?php
/*
 * Copyright (C) 2025		Mohamed DAOUD       <mdaoud@dolicloud.com>
 * Copyright (C) 2025-2026	MDW					<mdeweerd@users.noreply.github.com>
 * Copyright (C) 2025       Frédéric France     <frederic.france@free.fr>
 *
 * This program is free software; you can redistribute it and/or modifyion 2.0 (the "License");
 * it under the terms of the GNU General Public License as published bypliance with the License.
 * the Free Software Foundation; either version 3 of the License, or
 *
 * 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/>.
 * or see https://www.gnu.org/
 */

include_once DOL_DOCUMENT_ROOT.'/core/lib/admin.lib.php';

/**
 * Class ExternalModules
 */
class ExternalModules
{
	/**
	 * @var int Pagination: current page
	 */
	public $no_page;

	/**
	 * @var int Pagination: display per page
	 */
	public $per_page;
	/**
	 * @var int The current categorie
	 */
	public $categorie;

	/**
	 * @var string The search keywords
	 */
	public $search;

	// setups
	/**
	 * @var string
	 */

	/**
	 * @var string GitHub YAML file URL
	 */
	public $file_source_url;

	/**
	 * @var string Cache file path for GitHub modules YAML file content (local)
	 */
	public $cache_file;

	/**
	 * // the url of this page
	 * @var string
	 */
	public $url;
	/**
	 * @var string
	 */
	public $shop_url; // the url of the shop
	/**
	 * @var string
	 */
	public $lang; // the integer representing the lang in the store
	/**
	 * @var bool
	 */
	public $debug_api; // useful if no dialog
	/**
	 * @var string
	 */
	public $dolistore_api_url;
	/**
	 * @var string
	 */
	public $dolistore_api_key;

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

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

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

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

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

	/**
	 * @var int // number of online providers
	 */
	public $numberOfProviders;

	/**
	 * @var array<int, mixed>|null
	 */
	public $products;

	/**
	 * @var int Total number of products
	 */
	public $numberTotalOfProducts;

	/**
	 * @var int Total number of pages
	 */
	public $numberTotalOfPages;

	/**
	 * @var int Number of products displayed on the page.
	 */
	public $numberOfProducts;

	/**
	 * Constructor
	 *
	 * @param	boolean		$debug		Enable debug of request on screen
	 */
	public function __construct($debug = false)
	{
		global $langs;

		$this->debug_api = $debug;

		$this->url       = DOL_URL_ROOT.'/admin/modules.php?mode=marketplace';

		// For dolistore modules
		$this->dolistore_api_url = getDolGlobalString('MAIN_MODULE_DOLISTORE_API_SRV', 'https://www.dolistore.com/api/');	// 'https://www.dolistore.com/api/', 'https://admin2.dolibarr.org/api/index.php/marketplace/'
		$this->dolistore_api_key = getDolGlobalString('MAIN_MODULE_DOLISTORE_API_KEY', 'dolistorepublicapi');
		$this->shop_url  = getDolGlobalString('MAIN_MODULE_DOLISTORE_SHOP_URL', 'https://www.dolistore.com');

		// For community modules
		$this->file_source_url = "https://raw.githubusercontent.com/Dolibarr/dolibarr-community-modules/refs/heads/main/index.yaml";
		$this->cache_file = DOL_DATA_ROOT.'/admin/temp/remote_github_modules_file.yaml';

		$lang       = $langs->defaultlang;
		$lang_array = array('en_US', 'fr_FR', 'es_ES', 'it_IT', 'de_DE');
		if (!in_array($lang, $lang_array)) {
			$lang = 'en_US';
		}
		$this->lang = $lang;
	}

	/**
	 * loadRemoteSources
	 *
	 * @param	boolean		$debug		Enable debug of request on screen
	 * @return	void
	 */
	public function loadRemoteSources($debug = false)
	{
		// Check access to Community repo
		if (getDolGlobalString('MAIN_ENABLE_EXTERNALMODULES_COMMUNITY')) {
			$cachedelayforgithubrepo = getDolGlobalInt('MAIN_REMOTE_GITHUBREPO_CACHE_DELAY', 86400);

			$this->getRemoteYamlFile($this->file_source_url, $cachedelayforgithubrepo);

			$this->githubFileError = $this->error;
			$this->githubFileStatus = dol_is_file($this->cache_file) ? 1 : 0;
		}

		// Check access to Dolistore API /api/categories -> /api/index.php/marketplace/categories
		if (getDolGlobalString('MAIN_ENABLE_EXTERNALMODULES_DOLISTORE')) {
			$this->dolistoreApiStatus = $this->checkApiStatus();
		}

		// Count the number of online providers
		$this->numberOfProviders = $this->dolistoreApiStatus + $this->githubFileStatus;
	}

	/**
	 * Test if we can access to remote Dolistore market place.
	 *
	 * @param string 						$resource 	Resource relative URL ('categories' or 'products')
	 * @param array<string, mixed>|false 	$options 	Options for the request
	 * @return array{status_code:int,response:null|string|array<string,mixed>}
	 */
	public function callApi($resource, $options = false)
	{
		// If no dolistore_api_key is set, we can't access the API
		if (empty($this->dolistore_api_key) || empty($this->dolistore_api_url)) {
			return array('status_code' => 0, 'response' => null);
		}

		// Add basic auth if needed
		$basicAuthLogin = getDolGlobalString('MAIN_MODULE_DOLISTORE_BASIC_LOGIN');
		$basicAuthPassword = getDolGlobalString('MAIN_MODULE_DOLISTORE_BASIC_PASSWORD');

		$httpheader = array('DOLAPIKEY: '.$this->dolistore_api_key);
		if ($basicAuthLogin) {
			$httpheader[] = 'Authorization: Basic '.base64_encode($basicAuthLogin.':'.$basicAuthPassword);
		}

		$url = $this->dolistore_api_url . (preg_match('/\/$/', $this->dolistore_api_url) ? '' : '/') . $resource;

		$options['apikey'] = $this->dolistore_api_key;

		if ($options) {
			$url .= '?' . http_build_query($options);
		}

		$response = getURLContent($url, 'GET', '', 1, $httpheader, array('https'), 0, -1, 5, 5);

		$status_code = $response['http_code'];
		$body = 'Error';

		if ($status_code == 200) {
			$body = $response['content'];
			$body = json_decode($body, true);
			$returnarray = array(
				'status_code' => $status_code,
				'response' => $body
			);
		} else {
			$returnarray = array(
				'status_code' => $status_code,
				'response' => $body
			);
			if (!empty($response['curl_error_no'])) {
				$returnarray['curl_error_no'] = $response['curl_error_no'];
			}
			if (!empty($response['curl_error_msg'])) {
				$returnarray['curl_error_msg'] = $response['curl_error_msg'];
			}
		}

		return $returnarray;
	}

	/**
	 * Fetch modules from a cache YAML file
	 * @param array<string, mixed> $options Options for filter
	 *
	 * @return list<array<string, array<string, string|null>|string|null>> List of modules
	 */
	public function fetchModulesFromFile($options = array())
	{
		$modules = array();

		if (!empty($this->cache_file) && file_exists($this->cache_file)) {
			dol_syslog(__METHOD__ . " - Loading cache file: " . $this->cache_file, LOG_DEBUG);

			$content = file_get_contents($this->cache_file);
			if ($content !== false) {
				$modules = $this->readYaml($content);
			} else {
				dol_syslog(__METHOD__ . " - Error reading cache file", LOG_ERR);
			}
		}

		return $modules;
	}

	/**
	 * Generate HTML for categories and their children.
	 * @param int $active The active category id
	 *
	 * @return string HTML string representing the categories and their children.
	 */
	public function getCategories($active = 0)
	{
		$organized_tree = array();
		$html = '';

		$data = [
			'lang' => $this->lang
		];

		$current = $active;

		$resCategories = $this->callApi('categories', $data);
		if (isset($resCategories['response']) && is_array($resCategories['response'])) {
			$organized_tree = $resCategories['response'];
		} else {
			return $html ;
		}

		$html = '';
		foreach ($organized_tree as $key => $value) {
			if ($value['label'] != "Versions" && $value['label'] != "Specials") {
				$html .= '<li' . ($current == $value['rowid'] ? ' class="active"' : '') . '>';
				$html .= '<a href="?mode=marketplace&categorie=' . $value['rowid'] . '">' . $value['label'] . '</a>';
				if (isset($value['children'])) {
					$html .= '<ul>';
					usort($value['children'], $this->buildSorter('position'));
					foreach ($value['children'] as $key_children => $value_children) {
						$html .= '<li' . ($current == $value_children['rowid'] ? ' class="active"' : '') . '>';
						$html .= '<a href="?mode=marketplace&categorie=' . $value_children['rowid'] . '" title="' . dol_escape_htmltag(strip_tags($value_children['description'])) . '">' . $value_children['label'] . '</a>';
						$html .= '</li>';
					}
					$html .= '</ul>';
				}
				$html .= '</li>';
			}
		}
		return $html;
	}

	/**
	 * Generate HTML for products.
	 *
	 * @param 	array<string,mixed> 	$options 	Options for the request
	 * @return 	string|null 						HTML string representing the products.
	 */
	public function getProducts($options)
	{
		global $langs;

		$langs->load("products");

		$html       = "";
		$last_month = dol_now() - (30 * 24 * 60 * 60);
		$dolibarrversiontouse = DOL_VERSION;	// full string with version

		$this->products = array();

		$this->categorie = $options['categorie'] ?? 0;
		$this->per_page  = $options['per_page'] ?? 11;
		$this->no_page  = $options['no_page'] ?? 1;
		$this->search    = $options['search'] ?? '';

		$this->per_page = 11;	// We fix number of products per page to 11

		// Length of $search must be at least 2 characters
		if (!empty($this->search) && strlen(str_replace(' ', '', (string) $this->search)) < 2) {
			$html .= '<tr class=""><td colspan="3" class="center">';
			$html .= '<br><br>';
			$html .= $langs->trans("SearchStringMinLength").'...';
			$html .= '<br><br>';
			$html .= '</td></tr>';
			return $html;
		}

		$data = [
			'categorieid' 	=> $this->categorie,
			'limit' 		=> $this->per_page,
			'page' 			=> $this->no_page,
			'search' 		=> $this->search,
			'lang' 			=> $this->lang
		];


		$this->numberTotalOfProducts = 0;

		// Special case of category goodies
		if ($this->categorie == 87) {
			$html = '<div class="shop-container">
                            <div class="shop-image">
								<a href="https://merch.dolibarr.org/" target="_blank">
	                                <img src="https://www.dolistore.com/medias/image/marketplace/img/goodies-shop.jpg" width="50%" alt="DoliStore Merch and Gifts" />
	                                <div class="shop-overlay">
	                                    <button target="new" class="shop-button">'.$langs->trans("GoodiesButtonTitle").' <i class="icon-chevron-right"></i></button>
	                                </div>
                                </a>
                            </div>
                        </div>';

			return $html;
		}

		// Fetch the products from Dolistore source

		$dolistoreProducts = array();
		$dolistoreProductsTotal = 0;
		if ($this->dolistoreApiStatus > 0 && getDolGlobalInt('MAIN_ENABLE_EXTERNALMODULES_DOLISTORE')) {
			$getDolistoreProducts = $this->callApi('products', $data);

			if (!isset($getDolistoreProducts['response']) || !is_array($getDolistoreProducts['response']) || ($getDolistoreProducts['status_code'] != 200 && $getDolistoreProducts['status_code'] != 201)) {
				$dolistoreProducts = array();
				$dolistoreProductsTotal = 0;
			} else {
				$dolistoreProducts = $this->adaptData($getDolistoreProducts['response']['products'], 'dolistore');
				$dolistoreProductsTotal = (int) $getDolistoreProducts['response']['total'];
				$this->numberTotalOfProducts += $dolistoreProductsTotal;
			}
		}

		// Fetch the products from the github repo

		$fileProducts = array();
		$fileProductsTotal = 0;
		if (!empty($this->githubFileStatus) && getDolGlobalInt('MAIN_ENABLE_EXTERNALMODULES_COMMUNITY')) {
			$fileProducts = $this->fetchModulesFromFile($data);			// Return an array with all modules from the cache filecontent in $data

			$fileProducts = $this->adaptData($fileProducts, 'githubcommunity');

			$fileProducts = $this->applyFilters($fileProducts, $data);

			$fileProductsTotal = $fileProducts['total'];

			$this->numberTotalOfProducts += $fileProductsTotal;

			$fileProducts = $fileProducts['data'];
		}

		// Number of pages
		$this->numberTotalOfPages = (int) ceil(max($fileProductsTotal / $this->per_page, $dolistoreProductsTotal / $this->per_page));

		// Merge both sources (github community modules have priority on dolistore).
		$this->products = $dolistoreProducts;
		foreach ($fileProducts as $fileProduct) {
			$id = $fileProduct['id'];
			if ($id > 0) {
				if (empty($this->products[$id])) {		// Not already present in array
					array_unshift($this->products, $fileProduct);
				} else {
					$this->products[$id] = $fileProduct;
					$this->products[$id]['category'] = $fileProduct['category'];
				}
			} else {
				array_unshift($this->products, $fileProduct);
			}
		}


		$i = 0;
		foreach ($this->products as $product) {
			$i++;

			// check new product ?
			$newapp = '';
			if ($last_month < strtotime($product['datec']) && $product["status"] != 'soon' && $product["status"] != 'development' && $product["status"] != 'experimental') {
				$newapp .= '<span class="newApp" title="'.$product['tms'].'">'.$langs->trans('New').'</span> ';
			}

			// check updated ?
			if ($newapp == '' && $last_month < strtotime($product['tms']) && $product["status"] != 'soon' && $product["status"] != 'development' && $product["status"] != 'experimental') {
				$newapp .= '<span class="updatedApp" title="'.$product['tms'].'">'.$langs->trans('UpdatedRecently').'</span> ';
			}

			// add image or default ?
			if ($product["cover_photo_url"] != '' && $product["cover_photo_url"] != '#') {
				$images = '<a href="'.$product["cover_photo_url"].'" class="documentpreview" target="_blank" rel="noopener noreferrer" mime="image/png" title="'.dol_escape_htmltag($product["label"].', '.$langs->trans('Version').' '.$product["module_version"]).'">';
				$images .= '<img class="imgstore" src="'.$product["cover_photo_url"].'" alt="" /></a>';
			} else {
				$images = '<img class="imgstore" src="'.DOL_URL_ROOT.'/public/theme/common/nophoto.png" />';
			}

			// Set and check version
			$version = '';
			$compatible = '';
			if ($product["status"] == 'soon' || $product["status"] == 'development' || $product["status"] == 'experimental') {
				$version = '<span class="warning">'.$langs->trans("NotYetAvailable").' - '.$langs->trans("StillInDevelopment").'</span>';
				$compatible = 'NotCompatible';
			} elseif ($this->versionCompare($product["dolibarr_min"], $dolibarrversiontouse) <= 0) {
				if (!empty($product["dolibarr_max"]) && $product["dolibarr_max"] != 'auto' && $product["dolibarr_max"] != 'unknown' && $this->versionCompare($product["dolibarr_max"], $dolibarrversiontouse) >= 0) {
					// Compatible
					$version = '<span class="compatible hideonsmartphone">'.$langs->trans(
						'CompatibleUpTo',
						$dolibarrversiontouse,
						$product["dolibarr_min"],
						$product["dolibarr_max"]
					).'</span>';
					$compatible = '';
				} else {
					// Never compatible, module expired
					$version = '<span class="warning">'.$langs->trans(
						'NotCompatible',
						$dolibarrversiontouse,
						$product["dolibarr_min"],
						$product["dolibarr_max"]
					).'</span>';
					$compatible = 'NotCompatible';
				}
			} else {
				if ($product["dolibarr_min"] == 'auto' || $product["dolibarr_min"] != 'unknown') {
					// Never compatible, module expired
					$version = '<span class="warning">'.$langs->trans(
						'NotCompatible',
						$dolibarrversiontouse,
						$product["dolibarr_min"],
						$product["dolibarr_max"]
					).'</span>';
					$compatible = 'NotCompatible';
				} else {
					// Need update
					$version = '<span class="compatibleafterupdate">'.$langs->trans(
						'CompatibleAfterUpdate',
						$dolibarrversiontouse,
						$product["dolibarr_min"],
						$product["dolibarr_max"]
					).'</span>';
					$compatible = 'NotCompatible';
				}
			}

			// free or pay ?
			$install_link = '';
			if (array_key_exists('price_ht', $product) && price2num($product["price_ht"]) > 0) {
				$price = '<h3>'.price(price2num($product["price_ht"], 'MT'), 0, $langs, 1, -1, -1, 'EUR').' '.$langs->trans("HT").'</h3>';

				$download_link = '<a class="paddingleft paddingright valignmiddle" target="_blank" title="'.$langs->trans("View").'" href="'.$this->shop_url.'/product.php?id='.((int) $product['id']).'">';
				$download_link .= img_picto('', 'url', 'class="size2x paddingright"');
				$download_link .= '</a>';
			} else {
				$download_link = '#';
				if ($product['source'] === 'dolistore') {	// 0 on dolistore may mean 0 or a complementary fee to subscribe
					$urlview = $this->shop_url.'/product.php?id='.((int) $product["id"]);
					$price = '<h3><a href="'.$urlview.'" target="_blank">'.$langs->trans('SeeOnDoliStore').'</a></h3>';
				} elseif ($product['source'] === 'githubcommunity') {
					if (array_key_exists('price_ht', $product) && empty($product['price_ht'])) {
						if ($product['status'] == 'soon') {
							$price = '<h3>'.$langs->trans('StillInDevelopment').'</h3>';
						} else {
							$price = '<h3>'.$langs->trans('Free').'</h3>';
						}
					} else {
						if ($product["dolistore-download"]) {
							$price = '<h3><a href="'.$product["dolistore-download"].'" target="_blank">'.$langs->trans('SeeOnDoliStore').'</a></h3>';
						} else {
							$price = '<h3>'.$langs->trans('Unknown').'</h3>';
						}
					}
				} else {
					$price = '<h3>'.$langs->trans('Unknown').'</h3>';
				}

				if ($product['source'] === 'githubcommunity') {
					$download_link = '<a class="paddingleft paddingright valignmiddle" target="_blank" title="'.$langs->trans("Sources").'"  href="'.$product["link"].'">';
					$download_link .= img_picto('', 'file-code', 'class="size2x paddingright colorgrey"');
					$download_link .= '</a>';

					$urlview = $product["dolistore-download"];		// View on Dolistore
					if ($urlview) {
						$download_link .= '<a class="paddingleft paddingright valignmiddle" target="_blank" title="'.$langs->trans("View").'" href="'.$urlview.'" rel="noopener noreferrer">';
						$download_link .= img_picto('', 'url', 'class="size2x"');
						$download_link .= '</a>';
					}

					if (!empty($product['direct-download']) && $product['direct-download'] == 'yes') {
						$reg = array();
						if (preg_match('/https:.*\?id=(\d+)$/', $urlview, $reg)) {
							$urldownload = 'https://www.dolistore.com/_service_download.php?t=free&p='.$reg[1];
							$download_link .= '<a class="paddingleft paddingright valignmiddle" target="_blank" title="'.$langs->trans("Download").'" href="'.$urldownload.'" rel="noopener noreferrer">';
							$download_link .= img_picto('', 'download', 'class="size2x paddingright"');
							//$download_link .= '<img width="32" src="'.DOL_URL_ROOT.'/admin/remotestore/img/download.png" />';
							$download_link .= '</a>';
						}
					}
				} elseif ($product['source'] === 'dolistore') {
					$urlview = $this->shop_url.'/product.php?id='.((int) $product["id"]);
					$urldownload = 'https://www.dolistore.com/_service_download.php?t=free&p=' . $product['id'];
					$download_link = '<a class="paddingleft paddingright valignmiddle" target="_blank" title="'.$langs->trans("View").'" href="'.$urlview.'">';
					$download_link .= img_picto('', 'url', 'class="size2x"');
					$download_link .= '</a>';
					$download_link .= '<a class="paddingleft paddingright" target="_blank" title="'.$langs->trans("Download").'" href="'.$urldownload.'" rel="noopener noreferrer">';
					$download_link .= img_picto('', 'download', 'class="size2x paddingright"');
					//$download_link .= '<img width="32" src="'.DOL_URL_ROOT.'/admin/remotestore/img/download.png" />';
					$download_link .= '</a>';
				}

				// Direct install
				if (($product['direct-download'] && in_array($product['direct-download'], array('yes', 'dolistore'))) || $product['source'] === 'dolistore') {
					$urldownload = '';

					if ($product['source'] === 'githubcommunity') {
						$current_version = $product['module_version'] ?? '';
						$module_name = strtolower(preg_replace('/@.*$/', '', $product['ref'] ?? ''));

						// Remove "-" followed by current version at the end of the string if it exists
						$module_name = preg_replace('/-' . preg_quote($current_version, '/') . '$/', '', $module_name);

						$urldownload = 'https://github.com/Dolibarr/dolibarr-community-modules/raw/refs/heads/main/dev/build/bin/module_' . $module_name . '-' . $current_version . '.zip';

						$reg = array();
						$urlview = $product["dolistore-download"];		// View on Dolistore

						// For community modules, we download from community repo.
						// But we can force to download from dolistore if MAIN_DOWNLOAD_FROM_DOLISTORE_IN_PRIORITY is set (less reliable, less up to date)
						if ($product["direct-download"] == 'dolistore' || getDolGlobalString("MAIN_DOWNLOAD_FROM_DOLISTORE_IN_PRIORITY")) {
							if (preg_match('/https:.*\?id=(\d+)$/', $urlview, $reg)) {
								$urldownload = 'https://www.dolistore.com/_service_download.php?t=free&p='.$reg[1];
							}
						}
					}
					if ($product['source'] === 'dolistore') {
						$urldownload = 'https://www.dolistore.com/_service_download.php?t=free&p=' . $product['id'];
					}


					$disableInstall = ($compatible === 'NotCompatible') && !getDolGlobalInt('MAIN_FEATURES_LEVEL');
					// $disableInstall = false; // TODO: remove this.
					$disableInfo = $disableInstall ? dol_string_nohtmltag($version) : '';
					$fields = ['action' => 'install', 'token' => newToken()];
					foreach ($product as $key => $value) {
						$fields['producttoinstall['.$key.']'] = $value;
					}

					$installConfirmMessage = $langs->transnoentities(
							"extModuleConfirmInstallText",
							$product['label'] ?? '',
							$product['module_version'] ?? '',
							$product['ref'] ?? '',
							!empty($product['tms']) ? dol_print_date($product['tms'], '%d/%m/%Y') : ''
						);
					$installConfirmMessage .= $langs->trans("Path").' : '.$urldownload;

					$install_link = '<button class="valignmiddle ' . ($disableInstall ? 'butActionRefused' : 'butAction') . ' paddingleft paddingright"'
						. ($disableInfo     ? ' title="' . dol_escape_htmltag($disableInfo) . '"' : '')
						. (!$disableInstall ? ' data-confirm' : '')
						. (!$disableInstall ? ' data-fields="' . dol_escape_htmltag(json_encode($fields)) . '"' : '')
						. (!$disableInstall ? ' data-url="' . dol_escape_htmltag($this->url) . '"' : '')
						. (!$disableInstall ? ' data-confirm-title="' . dol_escape_htmltag($langs->trans("extModuleConfirmInstallTitle")) . '"' : '')
						. (!$disableInstall ? ' data-confirm-text="' . dol_escape_htmltag($installConfirmMessage) . '"' : '')
						. '>' . $langs->trans("Install") . '</button>';
				}
			}

			// Output the line
			$html .= '<tr class="'.(getDolOptimizeSmallScreen() ? 'app' : 'app app2').' oddeven nohover '.dol_escape_htmltag($compatible).'">';

			// Logo
			$html .= '<td class="center width150"><div class="newAppParent">';
			$html .= $newapp.$images;	// No dol_escape_htmltag, it is already escape html
			$html .= '</div></td>';

			// Description
			$html .= '<td class="margeCote minwidth400imp"><h2 class="appTitle">';
			$html .= dolPrintHTML(dol_string_nohtmltag(ucfirst($product["label"])));
			if (!empty($product['author']) && $product['author'] != 'unkownauthor') {
				$html .= '<span class="small"> &nbsp; - &nbsp; '.img_picto('', 'company', 'class="pictofixedwidth"');
				if (!empty($product['author_url'])) {
					$html .= '<a href="'.$product['author_url'].'" target="_blank">'.$product['author'].'</a>';
				} else {
					$html .= $product['author'];
				}
				$html .= '</span>';
			}
			$html .= '<br><span class="small">';
			$html .= $version;			// Version Dolibarr. No dol_escape_htmltag, it is already escape html
			$html .= '</span>';
			$html .= '</h2>';

			$html .= '<small class="appDateCreation appRef"> ';
			if (empty($product['tms'])) {
				$html .= img_picto($langs->trans('DateCreation'), 'calendar', 'class="pictofixedwidth"').'<span class="opacitymedium"><span class="hideonsmartphone">'.$langs->trans("DateCreation").': </span>';
				$html .= (!empty($product['datec']) ? dol_print_date(dol_stringtotime($product['datec']), 'day') : $langs->trans("Unknown")).'</span>';
			} else {
				$html .= img_picto($langs->trans('DateModification'), 'calendar', 'class="pictofixedwidth"').'<span class="opacitymedium">'.dol_print_date(dol_stringtotime($product['tms']), 'day').'</span>';
			}
			$html .= ' &nbsp; &nbsp; ';

			$html .= '<div class="appSource inline-block valigntop">';
			if ($product["source"] == 'dolistore') {
				$html .= '<img border="0" title="'.dolPrintHTML($langs->trans('Source').": DoliStore").'" class="imgautosize valignmiddle inline-block pictofixedwidth" style="height: 14px" src="'.DOL_URL_ROOT.'/theme/dolistore_squarred.svg">';
			} elseif ($product["source"] == 'githubcommunity') {
				$html .= img_picto($langs->trans('Source').': GitHub community repo', 'group', 'class="pictofixedwidth valignmiddle"');
			} else {
				$html .= img_picto($langs->trans('Source').': '.$langs->trans('Other'), 'generic', 'class="pictofixedwidth"');
			}
			$html .= '</div>';

			$html .= $langs->trans('Ref').' '.dolPrintHTML(preg_replace('/@.*$/', '', $product["ref"]));
			$html .= '</small><br>';


			$html .= '&nbsp;';
			if (!empty($product['phpmin']) && $product['phpmin'] != 'unknown') {
				$html .= ' <span class="badge-secondary small" style="padding: 3px; border-radius: 5px">PHP min '.$product['phpmin'].'</span>';
			}
			if (!empty($product['phpmax']) && $product['phpmax'] != 'unknown') {
				$html .= ' <span class="badge-secondary small" style="padding: 3px; border-radius: 5px">PHP max '.$product['phpmax'].'</span>';
			}
			$html .= '<br>';

			$html .= '<br>';
			$html .= '<div class="storedesc">'.dolPrintHTML(dol_string_nohtmltag($product["description"])).'</div>';
			$html .= '</td>';

			if (getDolOptimizeSmallScreen()) {
				$html .= '</tr><tr class="app2 oddeven nohover borderbottom '.dol_escape_htmltag($compatible).'">';
			}

			// Price - do not load if display none
			$html .= '<td class="margeCote center amount'.(getDolOptimizeSmallScreen() ? ' left" colspan="2"' : '"').'>';
			$html .= $price;
			if (($product['direct-download'] && in_array($product['direct-download'], array('yes', 'dolistore'))) || ($product['source'] === 'dolistore' && empty((float) $product['price_ht']))) {
				if ($install_link) {
					$html .= $install_link;
				}
			}

			if (!getDolOptimizeSmallScreen()) {
				$html .= '</td>';
				$html .= '<td class="margeCote nowraponall">';
			}

			// Links
			$html .= $download_link;
			$html .= '</td>';

			$html .= '</tr>';
		}

		if (empty($this->products)) {
			$colspan = (getDolOptimizeSmallScreen() ? 1 : 3);
			$langs->load("website");

			$html .= '<tr class=""><td colspan="'.$colspan.'" class="center">';
			$html .= '<br><br>';
			$html .= $langs->trans("noResultsWereFound").'...';
			$html .= '<br><br>';
			$html .= '</td></tr>';
		}

		// JS for confirm install
		$confirmLabel = dol_escape_js($langs->trans("Install"));
		$cancelLabel = dol_escape_js($langs->trans("Cancel"));
		$html .= '<script>
		$(document).on("click","[data-confirm]",function(){
			var button = $(this);
			var confirmTitle = button.data("confirm-title");
			var confirmText = button.data("confirm-text");
			var buttons = {};
			buttons[button.data("confirm-label")||"' . $confirmLabel . '"] = function(){
				var form = $("<form method=\'POST\' style=\'display:none\'>").attr("action", button.data("url"));
				$.each(button.data("fields"), function(name, value){
					form.append($("<input type=\'hidden\'>").attr("name", name).val(value));
				});
				$("body").append(form);
				form.submit();
				$(this).dialog("close");
			};
			buttons["' . $cancelLabel . '"] = function(){$(this).dialog("close");};
			$("<div>").html(confirmText).dialog({
				title: confirmTitle,
				minWidth: 580,
				modal: true,
				buttons: buttons
			});
		});
		</script>';

		$this->numberOfProducts = count($this->products);

		return $html;
	}

	/**
	 * Sort an array by a key
	 *
	 * @param string $key Key to sort by
	 * @return Closure(array<string, mixed>, array<string, mixed>): int
	 */
	public function buildSorter(string $key): Closure
	{
		return
		/**
		 * @param array<string, mixed> $a
		 * @param array<string, mixed> $b
		 * @return int
		 */
		function (array $a, array $b) use ($key) {
			$valA = isset($a[$key]) && is_scalar($a[$key]) ? (string) $a[$key] : '';
			$valB = isset($b[$key]) && is_scalar($b[$key]) ? (string) $b[$key] : '';

			return strnatcmp($valA, $valB);
		};
	}

	/**
	 * version compare
	 *
	 * @param   string  $v1     version 1
	 * @param   string  $v2     version 2
	 * @return int              result of compare
	 */
	public function versionCompare($v1, $v2)
	{
		// Clean v1 and v2
		$v1 = str_replace(array('v', 'V'), '', $v1);
		$v2 = str_replace(array('v', 'V'), '', $v2);

		$v1       = explode('.', $v1);
		$v2       = explode('.', $v2);
		$ret      = 0;
		$level    = 0;
		$count1   = count($v1);
		$count2   = count($v2);
		$maxcount = max($count1, $count2);
		while ($level < $maxcount) {
			$operande1 = isset($v1[$level]) ? $v1[$level] : 'x';
			$operande2 = isset($v2[$level]) ? $v2[$level] : 'x';
			$level++;
			if (strtoupper($operande1) == 'X' || strtoupper($operande2) == 'X' || $operande1 == '*' || $operande2 == '*') {
				break;
			}
			if ($operande1 < $operande2) {
				$ret = -$level;
				break;
			}
			if ($operande1 > $operande2) {
				$ret = $level;
				break;
			}
		}
		//print join('.',$versionarray1).'('.count($versionarray1).') / '.join('.',$versionarray2).'('.count($versionarray2).') => '.$ret.'<br>'."\n";
		return $ret;
	}

	// phpcs:disable PEAR.NamingConventions.ValidFunctionName.ScopeNotCamelCaps
	/**
	 * get previous link
	 *
	 * @param   string    $text     symbol previous
	 * @return  string              html previous link
	 */
	public function get_previous_link($text = '<<')
	{
		// phpcs:enable
		return '<a href="'.$this->get_previous_url().'" class="button">'.dol_escape_htmltag($text).'</a>';
	}

	// phpcs:disable PEAR.NamingConventions.ValidFunctionName.ScopeNotCamelCaps
	/**
	 * get next link
	 *
	 * @param   string    $text     symbol next
	 * @return  string              html next link
	 */
	public function get_next_link($text = '>>')
	{
		// phpcs:enable
		return '<a href="'.$this->get_next_url().'" class="button">'.dol_escape_htmltag($text).'</a>';
	}

	// phpcs:disable PEAR.NamingConventions.ValidFunctionName.ScopeNotCamelCaps
	/**
	 * get previous url
	 *
	 * @return string    previous url
	 */
	public function get_previous_url()
	{
		// phpcs:enable
		$param_array = array();
		if ($this->no_page > 1) {
			$sub = 1;
		} else {
			$sub = 0;
		}
		if (!empty($this->search)) {
			$param_array['search_keyword'] = $this->search;
		}
		$param_array['no_page'] = $this->no_page - $sub;
		if ($this->categorie != 0) {
			$param_array['categorie'] = $this->categorie;
		}
		$param = http_build_query($param_array);
		return $this->url."&".$param;
	}

	// phpcs:disable PEAR.NamingConventions.ValidFunctionName.ScopeNotCamelCaps
	/**
	 * get next url
	 *
	 * @return string    next url
	 */
	public function get_next_url()
	{
		// phpcs:enable
		$param_array = array();
		if ($this->products !== null && count($this->products) < $this->per_page) {
			$add = 0;
		} else {
			$add = 1;
		}
		if (!empty($this->search)) {
			$param_array['search_keyword'] = $this->search;
		}
		$param_array['no_page'] = $this->no_page + $add;
		if ($this->categorie != 0) {
			$param_array['categorie'] = $this->categorie;
		}
		$param = http_build_query($param_array);
		return $this->url."&".$param;
	}

	/**
	 * Generate pagination for navigating through pages of products.
	 *
	 * @return string HTML string representing the pagination.
	 */
	public function getPagination()
	{

		global $langs;

		$page = $this->no_page;
		$limit = $this->per_page;
		$totalnboflines = $this->numberTotalOfProducts ?: 0;
		$num = $this->numberOfProducts;

		$html = "";

		// Show navigation bar
		$pagelist = '';
		if ($page > 0 || $num > $limit) {
			if ($totalnboflines) {
				if ($limit > 0) {
					$nbpages = $this->numberTotalOfPages;
				} else {
					$nbpages = 1;
				}

				// Show previous page
				if ($page > 1) {
					$pagelist .= '<li class="pagination paginationpage paginationpageleft"><a class="paginationprevious reposition" href="'.$this->get_previous_url().'"><i class="fa fa-chevron-left" title="'.dol_escape_htmltag($langs->trans("Previous")).'"></i></a></li>';
				}

				$pagelist .= '<li class="pagination">';
				$pagelist .= '<label for="page_input">Page </label>';
				if ($this->categorie != 0) {
					$pagelist .= '<input type="hidden" name="categorie" value="' . $this->categorie . '">';
				}
				$pagelist .= '<input type="text" id="page_input" name="no_page" value="'.($page).'" min="1" max="'.$nbpages.'" class="width40 page_input right" oninput="if(this.value > '.$nbpages.') this.value='.$nbpages.'">';
				$pagelist .= ' / '.$nbpages;
				$pagelist .= '</li>';

				// Show next page
				if ($page < $nbpages) {
					$pagelist .= '<li class="pagination paginationpage paginationpageright"><a class="paginationnext reposition" href="'.$this->get_next_url().'"><i class="fa fa-chevron-right" title="'.dol_escape_htmltag($langs->trans("Next")).'"></i></a></li>';
				}
			}
		}

		if ($limit || $pagelist) {
			$html .= '<div class="pagination" style="padding: 7px;">';
			$html .= '<ul>';
			$html .= $pagelist;
			$html .= '</ul>';
			$html .= '</div>';
		}

		$html .= ajax_autoselect('.page_input');

		return $html;
	}

	/**
	 * Check the status code of the request
	 *
	 * @param array{status_code:int,response:null|string|array{curl_error_msg:string,errors:array{code:int,message:string}[]}} $request Response elements of CURL request
	 * @return string|null
	 */
	protected function checkStatusCode($request)
	{
		// Define error messages
		$error_messages = [
			204 => 'No content',
			400 => 'Bad Request',
			401 => 'Unauthorized',
			404 => 'Not Found',
			405 => 'Method Not Allowed',
			500 => 'Internal Server Error',
		];

		// If status code is 200 or 201, return an empty string
		if ($request['status_code'] === 200 || $request['status_code'] === 201) {
			return '';
		}

		// Get the predefined error message or use a default one
		$error_message = $error_messages[$request['status_code']] ?? 'Unexpected HTTP status: ' . $request['status_code'];

		// Append error details if available
		if (!empty($request['response']) && isset($request['response']['errors']) && is_array($request['response']['errors'])) {
			foreach ($request['response']['errors'] as $error) {
				$error_message .= ' - (Code ' . $error['code'] . '): ' . $error['message'];
			}
		}

		if (!empty($request['curl_error_msg'])) {
			$error_message .= ' - ' . $request['curl_error_msg'];
		}

		// Return the formatted error message
		return sprintf('This call to the API failed and returned an HTTP status of %d. That means: %s.', $request['status_code'], $error_message);
	}

	/**
	 * Get YAML file from remote source and put it into the cache file
	 *
	 * @param 	string 		$file_source_url 	URL of the remote source
	 * @param 	int 		$cache_time 		Cache time
	 * @return 	bool|string 					File content
	 */
	public function getRemoteYamlFile($file_source_url, $cache_time)
	{
		$yaml = '';
		$cache_file = $this->cache_file;
		$cache_folder = dirname($cache_file);

		// Check if cache directory exists
		if (!dol_is_dir($cache_folder)) {
			dol_mkdir($cache_folder, DOL_DATA_ROOT);
		}

		if (!file_exists($cache_file) || filemtime($cache_file) < (dol_now() - $cache_time)) {
			// We get remote url
			$addheaders = array();
			$result = getURLContent($file_source_url, 'GET', '', 1, $addheaders);	// TODO Force timeout to 5 s on both connect and response.
			if (!empty($result) && $result['http_code'] == 200) {
				$yaml = $result['content'];
				$result = file_put_contents($cache_file, $yaml);
				if ($result === false) {
					$this->error = 'Failed to create cache file: ' . $cache_file;
				} else {
					dolChmod($cache_file);
				}
			}
		} else {
			$yaml = file_get_contents($cache_file);
		}

		return $yaml;
	}


	/**
	 * Read a YAML string and convert it to an array
	 *
	 * @param string $yaml YAML string
	 * @return list<array<string, array<string, string|null>|string|null>> Parsed array representation
	 */
	public function readYaml($yaml)
	{
		$data = [];
		$currentPackage = null;
		$currentSection = null;

		foreach (explode("\n", trim($yaml)) as $line) {
			$trimmedLine = trim($line);

			// Ignore empty lines and comments
			if ($trimmedLine === '' || strpos($trimmedLine, '#') === 0) {
				continue;
			}

			// Match a new package entry (e.g., "- modulename: 'helloasso'") - Found a break in file.
			$matches = array();
			if (preg_match('/^\s*-\s*modulename:\s*["\']?(.*?)["\']?$/', $trimmedLine, $matches)) {
				if ($currentPackage !== null) {
					// Add the package to $data
					if (!empty($currentPackage['status']) && in_array($currentPackage['status'], array('enabled', 'soon'))) {
						$data[] = $currentPackage;
					}
				}
				$currentPackage = ['modulename' => $matches[1]];
				$currentSection = null;
				continue;
			}

			// If the key doesn't start with fr, en, es, it, de, treat it as a section
			if (!preg_match('/^\s*(fr|en|es|it|de):\s*["\']?(.*?)["\']?$/', $trimmedLine)) {
				$currentSection = null;
			}

			// Match a top-level key-value pair (e.g., "author: 'Dolicloud'")
			if (preg_match('/^(\w[\w-]*):\s*["\']?(.*?)["\']?$/', $trimmedLine, $matches)) {
				if ($currentPackage !== null) {
					if ($currentSection) {
						// Store in the sub section (language into label or description for example)
						$currentPackage[$currentSection][$matches[1]] = $matches[2] === '' ? null : $matches[2];
					} else {
						// Store as a normal key-value pair
						$currentPackage[$matches[1]] = $matches[2] === '' ? null : $matches[2];
					}
				}

				// Match a nested section (e.g., "label:")
				if (preg_match('/^\s*(label|description):\s*$/', $trimmedLine, $matches)) {
					$currentSection = $matches[1];
					$currentPackage[$currentSection] = []; // Initialize as an empty array for nested sections
				}

				continue;
			}
		}

		// Add the last package if available
		if ($currentPackage !== null) {
			if (!empty($currentPackage['status']) && in_array($currentPackage['status'], array('enabled', 'soon'))) {
				$data[] = $currentPackage;
			}
		}

		return $data;
	}

	/**
	 * Adapter data fetched from github remote source to the expected format
	 *
	 * @param array<string, mixed>|list<array<string, array<string, string|null>|string|null>> $data 	Data fetched from github remote source
	 * @param string $source 	Source of the data
	 * @return list<array<string, array<string, string|null>|string|null>> Data adapted to the expected format
	 */
	public function adaptData($data, $source)
	{
		$adaptedData = [];

		if (!is_array($data) || empty($data) || empty($source)) {
			return $adaptedData;
		}

		if ($source === 'githubcommunity') {
			foreach ($data as $package) {
				if (empty($package['modulename'])) {
					continue;
				}

				// Check if there is a known ID
				$reg = array();
				$id = 0;
				if (!empty($package['dolistore-download']) && preg_match('/www\.dolistore\.com\/product\.php\?id=(\d+)/', (string) $package['dolistore-download'], $reg)) {
					$id = $reg[1];
				}

				$adaptedPackage = [
					'id' => $id,
					'ref' => str_replace(' ', '', $package['modulename'] . '-' . $package['current_version'] . '@' .
						(array_key_exists('author', $package) ? $package['author'] : 'unkownauthor')),
					'label' => !empty($package['label'][substr($this->lang, 0, 2)])
						? $package['label'][substr($this->lang, 0, 2)]
						: (!empty($package['label']['en']) ? $package['label']['en'] : $package['modulename']),
					'description' => !empty($package['description'][substr($this->lang, 0, 2)])
						? $package['description'][substr($this->lang, 0, 2)]
						: (!empty($package['description']['en']) ? $package['description']['en'] : ''),
					'datec' => (!empty($package['created_at']) && is_string($package['created_at']))
						? date('Y-m-d H:i:s', strtotime($package['created_at']))
						: '',
					'tms' => (!empty($package['last_updated_at']) && is_string($package['last_updated_at']))
						? date('Y-m-d H:i:s', strtotime($package['last_updated_at']))
						: '',
					'author' => array_key_exists('author', $package) ? $package['author'] : '',
					'author_url' => array_key_exists('author_url', $package) ? $package['author_url'] : '',
					'dolibarr_min' => !empty($package['dolibarrmin'])
						? $package['dolibarrmin']
						: 'unknown',
					'dolibarr_max' => !empty($package['dolibarrmax'])
						? $package['dolibarrmax']
						: 'unknown',
					'phpmin' => !empty($package['phpmin'])
						? $package['phpmin']
						: 'unknown',
					'phpmax' => !empty($package['phpmax'])
						? $package['phpmax']
						: 'unknown',
					'module_version' => !empty($package['current_version'])
						? $package['current_version']
						: 'unknown',
					'cover_photo_url' => !empty($package['cover'])
						? $package['cover']
						: '#',
					'category' => (!empty($package['category']) && is_string($package['category']))
						? explode(',', str_replace(' ', '', (string) $package['category']))
						: array(),
					'link' => !empty($package['git'])
						? $package['git']
						: '#',
					'source' => 'githubcommunity',
					'status' => !empty($package['status']) ? $package['status'] : '',
					'direct-download' => !empty($package['direct-download'])
						? $package['direct-download']
						: '',
					'dolistore-download' => !empty($package['dolistore-download'])
						? $package['dolistore-download']
						: '',
				];

				// If a price entry exists
				if (array_key_exists('price', $package) && $package['price'] != null) {
					$adaptedPackage['price_ht'] = $package['price'];
				}

				$adaptedData[] = $adaptedPackage;
			}
		}

		if ($source === 'dolistore') {
			foreach ($data as $package) {
				$urlphoto = $this->shop_url.$package['cover_photo_url'];

				if (preg_match('/^\/?wrapper\.php\?hashp=/', $package['cover_photo_url']) && !preg_match('/attachment=/', $package['cover_photo_url'])) {
					$urlphoto .= '&attachment=0';
				}

				$adaptedPackage = [
					'id' => $package['id'],
					'ref' => $package['ref'],
					'label' => $package['label'],
					'description' => $package['description'],
					'datec' => $package['datec'],
					'tms' => $package['tms'],
					'author' => array_key_exists('author', $package) ? $package['author'] : '',
					'author_url' => array_key_exists('author_url', $package) ? $package['author_url'] : '',
					'price_ttc' => $package['price_ttc'],
					'price_ht' => $package['price_ht'],
					'dolibarr_min' => $package['dolibarr_min'],
					'dolibarr_max' => $package['dolibarr_max'],
					'phpmin' => empty($package['phpmin']) ? '' : $package['phpmin'],
					'phpmax' => empty($package['phpmax']) ? '' : $package['phpmax'],
					'module_version' => $package['module_version'],
					'cover_photo_url' => $urlphoto,
					'source' => 'dolistore',
					'status' => empty($package['status']) ? '' : $package['status']
				];

				$adaptedData[$package['id']] = $adaptedPackage;
			}
		}

		return $adaptedData;
	}

	/**
	 * Apply filters to the data
	 * @param list<array<string, mixed>> $list Data to filter
	 * @param array<string, mixed> $options Options for the filter
	 *
	 * @return array{total:int, data:list<array<string, mixed>>} Filtered data
	 */
	public function applyFilters($list, $options)
	{
		$filteredData = $list;

		// Sort products list by datec
		usort(
			$filteredData,
			/**
			 * Compare creation times
			 * @param array<string, mixed> $a First product for comparison.
			 * @param array<string, mixed> $b Second product for comparison.
			 *
			 * @return int
			 */
			static function ($a, $b) {
				return strtotime($b['datec'] ?? '0') - strtotime($a['datec'] ?? '0');
			}
		);

		if (!empty($options['search'])) {
			$filteredData = array_filter(
				$filteredData,
				/**
				 * Filter packages that have a label or description with the search string
				 *
				 * @param array<string, mixed> $package
				 *
				 * @return bool
				 */
				static function ($package) use ($options) {
					return stripos($package['label'], $options['search']) !== false || stripos($package['description'], $options['search']) !== false;
				}
			);
		}

		if (!empty($options['categorieid'])) {
			$filteredData = array_filter(
				$filteredData,
				/**
				 * Filter the packages that belong to the filtered category
				 *
				 * @param array<string, mixed> $package
				 *
				 * @return bool
				 */
				static function ($package) use ($options) {
					return in_array($options['categorieid'], $package['category']);
				}
			);
		}

		$total = count($filteredData);

		// Pagination
		$filteredData = array_values($filteredData);
		$filteredData = array_slice($filteredData, ($options['page'] - 1) * $options['limit'], $options['limit']);

		return ['total' => $total, 'data' => $filteredData];
	}

	/**
	 * Check if an Dolistore API is up
	 *
	 * @return int
	 */
	public function checkApiStatus()
	{
		// Call remote API
		$testRequest = $this->callApi('categories');

		if (!isset($testRequest['response']) || !is_array($testRequest['response']) || ($testRequest['status_code'] != 200 && $testRequest['status_code'] != 201)) {
			$this->dolistoreApiError = $this->checkStatusCode($testRequest);
			return 0;
		} else {
			return 1;
		}
	}

	/**
	 * Retrieve the status icon
	 *
	 * @param 	mixed 	$status 	Status
	 * @param 	mixed 	$mode		Mode
	 * @param	string	$moretext	More text to show on tooltip
	 * @return 	string
	 */
	public function libStatus($status, $mode = 3, $moretext = '')
	{
		global $langs;

		$statusType = 'status4';
		if ($status == 0) {
			$statusType = 'status8';
		}

		$labelStatus = [];
		$labelStatusShort = [];

		$labelStatus[0] = $langs->transnoentitiesnoconv("NotConnected");
		$labelStatus[1] = $langs->transnoentitiesnoconv("online");
		$labelStatusShort[0] = $langs->transnoentitiesnoconv("NotConnected");
		$labelStatusShort[1] = $langs->transnoentitiesnoconv("online");

		return dolGetStatus($labelStatus[$status], $labelStatusShort[$status], '', $statusType, $mode, '', array('badgeParams' => array('attr' => array('class' => 'classfortooltip', 'title' => $labelStatusShort[$status].$moretext))));
	}


	/**
	 * Download a Dolibarr module from a Git repository URL or Dolistore download URL.
	 *
	 * @param  array<string, mixed> 	$producttoinstall Product information array
	 * @return string|false				Path to the final ZIP file, or false on error
	 */
	public function getModuleZIP($producttoinstall = array())
	{
		global $conf;

		// Check if cURL is available
		if (!function_exists('curl_init')) {
			dol_syslog(__METHOD__ . ': cURL is not available', LOG_ERR);
			return false;
		}

		// Check required fields
		if (empty($producttoinstall['ref'])) {
			dol_syslog(__METHOD__ . ': Missing producttoinstall', LOG_ERR);
			return false;
		}

		$current_version = $producttoinstall['module_version'] ?? '';
		$module_name = strtolower(preg_replace('/@.*$/', '', $producttoinstall['ref'] ?? ''));

		// Remove "-" followed by current version at the end of the string if it exists
		$module_name = preg_replace('/-' . preg_quote($current_version, '/') . '$/', '', $module_name);

		if (empty($module_name) || empty($current_version) || $current_version == 'unknown') {
			dol_syslog(__METHOD__ . ': Missing or unknown module name/version for product', LOG_ERR);
			return false;
		}

		// Create a temporary directory for the download
		$tmpdir = $conf->admin->dir_temp . '/remotestoredl';
		dol_mkdir($tmpdir);

		$downloaded = false;
		switch ($producttoinstall['source']) {
			case 'dolistore':
				if ($producttoinstall['id'] > 0) {
					$source_url = 'https://www.dolistore.com/_service_download.php?t=free&p=' . $producttoinstall['id'];
					$downloaded = $this->_downloadFile($source_url, $tmpdir);
					if (!$downloaded) {
						dol_syslog(__METHOD__ . ': Dolistore download failed: ' . $source_url, LOG_ERR);
						return false;
					}
				} else {
					dol_syslog(__METHOD__ . ': Invalid product ID for Dolistore download: ' . $producttoinstall['id'], LOG_ERR);
					return false;
				}
				break;
			case 'githubcommunity':
				if ($producttoinstall['direct-download'] && in_array($producttoinstall['direct-download'], array('yes', 'dolistore'))) {
					$source_url = 'https://github.com/Dolibarr/dolibarr-community-modules/raw/refs/heads/main/dev/build/bin/module_' . $module_name . '-' . $current_version . '.zip';
					$downloaded = $this->_downloadFile($source_url, $tmpdir);
					if (!$downloaded) {
						dol_syslog(__METHOD__ . ': GitHub community module download failed: ' . $source_url . ', Try to find a Dolistore link', LOG_WARNING);
						if ($producttoinstall['id'] > 0) {
							$source_url = 'https://www.dolistore.com/_service_download.php?t=free&p=' . $producttoinstall['id'];
							$downloaded = $this->_downloadFile($source_url, $tmpdir);
							if (!$downloaded) {
								dol_syslog(__METHOD__ . ': Dolistore download failed: ' . $source_url, LOG_ERR);
								return false;
							}
						} else {
							dol_syslog(__METHOD__ . ': No direct download available for this GitHub community module', LOG_ERR);
							return false;
						}
					}
				} else {
					dol_syslog(__METHOD__ . ': No direct download available for this GitHub community module', LOG_ERR);
					return false;
				}
				break;
			default:
				dol_syslog(__METHOD__ . ': Unsupported source type: ' . $producttoinstall['source'], LOG_ERR);
		}


		dol_syslog(__METHOD__ . ': Module downloaded successfully to: ' . $downloaded, LOG_DEBUG);
		return $downloaded;
	}


	/**
	 * Download a remote URL to a local file using getURLContent (native Dolibarr).
	 *
	 * @param  string  $url         Remote URL to download
	 * @param  string  $dest_path   Local path to write the downloaded file (directory, not including filename)
	 * @return string|false         Full path of downloaded file on success, false on failure
	 */
	private function _downloadFile(string $url, string $dest_path)
	{
		// HEAD request to get real filename from Content-Disposition
		$filename = '';
		$head = getURLContent($url, 'HEAD');
		// Try to extract filename from Content-Disposition header
		if (!empty($head['header'])) {
			if (preg_match_all('/Content-Disposition:.*filename=["\']?([^"\';\r\n]+)/i', $head['header'], $m)) {
				$filename = trim(end($m[1]), " \t\"'");
			}
		}

		// If filename is not found in headers, try to extract it from URL
		if (empty($filename)) {
			$filename = basename(parse_url($url, PHP_URL_PATH));
		}

		// If filename is still empty or file name ne match the expected pattern (module_modulename-version.zip), log error and return false
		if (empty($filename) || !preg_match('/^module_[a-z0-9_]+-[0-9]+\.[0-9]+\.[0-9]+\.zip$/i', $filename)) {
			dol_syslog(__METHOD__ . ': Cannot determine filename from URL: ' . $url, LOG_ERR);
			return false;
		}

		// Download the file
		$response = getURLContent($url, 'GET');
		if (empty($response['content']) || (isset($response['http_code']) && $response['http_code'] !== 200)) {
			dol_syslog(
				__METHOD__ . ': Download failed — HTTP ' . ($response['http_code'] ?? 'unknown') . ' — ' . $url,
				LOG_WARNING
			);
			return false;
		}

		// Write to destination
		$dest_file = $dest_path . '/' . $filename;
		if (file_exists(dol_osencode($dest_file))) { // If file already exists, try to delete it first
			chmod(dol_osencode($dest_file), 0755);
			@unlink(dol_osencode($dest_file));
		}
		$writtenfile = file_put_contents(dol_osencode($dest_file), $response['content']);
		if ($writtenfile === false || $writtenfile === 0) {
			dol_syslog(__METHOD__ . ': Cannot write file: ' . $dest_file, LOG_ERR);
			@unlink(dol_osencode($dest_file));
			return false;
		}

		dol_syslog(__METHOD__ . ': Downloaded successfully to: ' . $dest_file, LOG_DEBUG);
		return $dest_file;
	}
}
