<?php

/**
 * Matomo - free/libre analytics platform
 *
 * @link    https://matomo.org
 * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
 */

namespace Piwik\Plugins\SitesManager;

use DateTimeZone;
use Exception;
use Matomo\Network\IPUtils;
use Piwik\Access;
use Piwik\Common;
use Piwik\Concurrency\Lock;
use Piwik\Concurrency\LockBackend;
use Piwik\Config;
use Piwik\Container\StaticContainer;
use Piwik\DataAccess\Model as CoreModel;
use Piwik\Date;
use Piwik\Exception\UnexpectedWebsiteFoundException;
use Piwik\Intl\Data\Provider\CurrencyDataProvider;
use Piwik\Measurable\Type\TypeManager;
use Piwik\Option;
use Piwik\Piwik;
use Piwik\Plugin\SettingsProvider;
use Piwik\Request\AuthenticationToken;
use Piwik\Plugins\CorePluginsAdmin\SettingsMetadata;
use Piwik\Plugins\SitesManager\Settings\FilterPIIParameters;
use Piwik\Plugins\SitesManager\SiteContentDetection\ConsentManagerDetectionAbstract;
use Piwik\Plugins\SitesManager\SiteContentDetection\SiteContentDetectionAbstract;
use Piwik\Plugins\WebsiteMeasurable\Settings\Urls;
use Piwik\ProxyHttp;
use Piwik\Scheduler\Scheduler;
use Piwik\Settings\Measurable\MeasurableProperty;
use Piwik\SettingsPiwik;
use Piwik\SettingsServer;
use Piwik\Site;
use Piwik\SiteContentDetector;
use Piwik\Tracker\Cache;
use Piwik\Tracker\TrackerCodeGenerator;
use Piwik\Translation\Translator;
use Piwik\Url;
use Piwik\UrlHelper;
use Piwik\Validators\WhitelistedValue;

/**
 * The SitesManager API gives you full control on Websites in Matomo (create, update and delete), and many methods to retrieve websites based on various attributes.
 *
 * This API lets you create websites via "addSite", update existing websites via "updateSite" and delete websites via "deleteSite".
 * When creating websites, it can be useful to access internal codes used by Matomo for currencies via "getCurrencyList", or timezones via "getTimezonesList".
 *
 * There are also many ways to request a list of websites: from the website ID via "getSiteFromId" or the site URL via "getSitesIdFromSiteUrl".
 * Often, the most useful technique is to list all websites that are known to a current user, based on the token_auth, via
 * "getSitesWithAdminAccess", "getSitesWithViewAccess" or "getSitesWithAtLeastViewAccess" (which returns both).
 *
 * Some methods will affect all websites globally: "setGlobalExcludedIps" will set the list of IPs to be excluded on all websites,
 * "setGlobalExcludedQueryParameters" will set the list of URL parameters to remove from URLs for all websites.
 * The existing values can be fetched via "getExcludedIpsGlobal" and "getExcludedQueryParametersGlobal".
 * See also the documentation about <a href='https://matomo.org/docs/manage-websites/' rel='noreferrer' target='_blank'>Managing Websites</a> in Matomo.
 *
 * @phpstan-type SiteData array{
 *     idsite: int,
 *     name: string,
 *     description: string,
 *     main_url: string,
 *     ts_created: string,
 *     timezone: string,
 *     currency: string,
 *     ecommerce: int,
 *     sitesearch: int,
 *     exclude_unknown_urls: int,
 *     excluded_ips: string,
 *     excluded_parameters: string,
 *     excluded_user_agents: string,
 *     group: string,
 *     type: string,
 *     keep_url_fragment: int,
 *     creator_login?: string,
 *     timezone_name?: string,
 *     currency_name?: string
 * }
 * @phpstan-type SettingValues array<string, list<array{name: string, value: mixed}>>
 *
 * @method static \Piwik\Plugins\SitesManager\API getInstance()
 */
class API extends \Piwik\Plugin\API
{
    public const DEFAULT_SEARCH_KEYWORD_PARAMETERS = 'q,query,s,search,searchword,k,keyword,keywords';
    public const OPTION_EXCLUDED_IPS_GLOBAL = 'SitesManager_ExcludedIpsGlobal';
    public const OPTION_DEFAULT_TIMEZONE = 'SitesManager_DefaultTimezone';
    public const OPTION_DEFAULT_CURRENCY = 'SitesManager_DefaultCurrency';
    public const OPTION_EXCLUDED_QUERY_PARAMETERS_GLOBAL = 'SitesManager_ExcludedQueryParameters';
    public const OPTION_SEARCH_KEYWORD_QUERY_PARAMETERS_GLOBAL = 'SitesManager_SearchKeywordParameters';
    public const OPTION_SEARCH_CATEGORY_QUERY_PARAMETERS_GLOBAL = 'SitesManager_SearchCategoryParameters';
    public const OPTION_EXCLUDED_USER_AGENTS_GLOBAL = 'SitesManager_ExcludedUserAgentsGlobal';
    public const OPTION_EXCLUDED_REFERRERS_GLOBAL = 'SitesManager_ExcludedReferrersGlobal';
    public const OPTION_KEEP_URL_FRAGMENTS_GLOBAL = 'SitesManager_KeepURLFragmentsGlobal';
    public const OPTION_EXCLUDE_TYPE_QUERY_PARAMS_GLOBAL = 'SitesManager_ExcludeTypeQueryParamsGlobal';

    /**
     * @var SettingsProvider
     */
    private $settingsProvider;

    /**
     * @var SettingsMetadata
     */
    private $settingsMetadata;

    /**
     * @var Translator
     */
    private $translator;

    /** @var array<string, string> */
    private $timezoneNameCache = [];

    /** @var SiteContentDetector */
    private $siteContentDetector;

    /** @var TypeManager */
    private $typeManager;

    public function __construct(
        SettingsProvider $provider,
        SettingsMetadata $settingsMetadata,
        Translator $translator,
        SiteContentDetector $siteContentDetector,
        TypeManager $typeManager
    ) {
        $this->settingsProvider = $provider;
        $this->settingsMetadata = $settingsMetadata;
        $this->translator = $translator;
        $this->siteContentDetector = $siteContentDetector;
        $this->typeManager = $typeManager;
    }

    /**
     * Returns the JavaScript tracking tag for the given website.
     *
     * This tag must be included on every page to be tracked by Matomo. The generated code
     * initialises the Matomo JavaScript tracker with the specified options.
     *
     * @param int $idSite The numeric ID of the website to generate tracking code for.
     * @param string $piwikUrl The domain and URL path to the Matomo installation. Defaults to the current Matomo URL.
     * @param bool $mergeSubdomains Whether to track visitors across all subdomains of the site.
     * @param bool $groupPageTitlesByDomain Whether to prepend the site domain to the page title when tracking.
     * @param bool $mergeAliasUrls Whether to treat alias URLs as part of the same website for outlink detection.
     * @param array<int, array{string, string}> $visitorCustomVariables Custom visitor-scope variables as `array(array('name', 'value'), ...)`.
     * @param array<int, array{string, string}> $pageCustomVariables Custom page-scope variables as `array(array('name', 'value'), ...)`.
     * @param string $customCampaignNameQueryParam Custom campaign name query parameter name, overriding the default.
     * @param string $customCampaignKeywordParam Custom campaign keyword query parameter name, overriding the default.
     * @param bool $doNotTrack Whether to respect the browser's Do-Not-Track setting.
     * @param bool $disableCookies Whether to disable all first-party cookies.
     * @param bool $trackNoScript Whether to include a `<noscript>` image-based fallback for JavaScript-disabled browsers.
     * @param bool $crossDomain Whether to enable cross-domain linking.
     * @param bool $forceMatomoEndpoint Whether the Matomo endpoint should be forced if Matomo was installed prior to 3.7.0.
     * @param string|string[] $excludedQueryParams Array or comma-separated string of query parameters to exclude from page URLs.
     * @param string|string[] $excludedReferrers Array or comma-separated string of referrer hosts/URLs to ignore.
     * @param bool $disableCampaignParameters Whether to prevent campaign parameters from being sent to the tracker.
     * @return string The JavaScript tag ready to be included in HTML pages.
     * @unsanitized
     */
    public function getJavascriptTag(
        int $idSite,
        string $piwikUrl = '',
        bool $mergeSubdomains = false,
        bool $groupPageTitlesByDomain = false,
        bool $mergeAliasUrls = false,
        array $visitorCustomVariables = [],
        array $pageCustomVariables = [],
        string $customCampaignNameQueryParam = '',
        string $customCampaignKeywordParam = '',
        bool $doNotTrack = false,
        bool $disableCookies = false,
        bool $trackNoScript = false,
        bool $crossDomain = false,
        bool $forceMatomoEndpoint = false,
        $excludedQueryParams = '',
        $excludedReferrers = '',
        bool $disableCampaignParameters = false
    ): string {
        Piwik::checkUserHasViewAccess($idSite);

        if (empty($piwikUrl)) {
            $piwikUrl = SettingsPiwik::getPiwikUrl() ?: '';
        }

        if (is_array($excludedQueryParams)) {
            $excludedQueryParams = implode(',', $excludedQueryParams);
        }

        $generator = new TrackerCodeGenerator();
        if ($forceMatomoEndpoint) {
            $generator->forceMatomoEndpoint();
        }

        $code = $generator->generate(
            $idSite,
            $piwikUrl,
            $mergeSubdomains,
            $groupPageTitlesByDomain,
            $mergeAliasUrls,
            $visitorCustomVariables,
            $pageCustomVariables,
            $customCampaignNameQueryParam,
            $customCampaignKeywordParam,
            $doNotTrack,
            $disableCookies,
            $trackNoScript,
            $crossDomain,
            $excludedQueryParams,
            $excludedReferrers,
            $disableCampaignParameters
        );

        return str_replace(['<br>', '<br />', '<br/>'], '', $code);
    }

    /**
     * Returns image link tracking code for a given site with specified options.
     *
     * @param int $idSite The ID to generate tracking code for.
     * @param string $piwikUrl The domain and URL path to the Matomo installation.
     * @param string|false $actionName Action name to include in the image tracking request, or `false` to omit it.
     * @param int|false $idGoal Goal ID to trigger a conversion for, or `false` to omit goal tracking.
     * @param int|float|false $revenue Revenue for the goal conversion. Only used when `$idGoal` is supplied.
     * @param bool $forceMatomoEndpoint Whether the Matomo endpoint should be forced if Matomo was installed
     *                                  prior to 3.7.0.
     * @return string The HTML-encoded image tracking code.
     */
    public function getImageTrackingCode(
        $idSite,
        $piwikUrl = '',
        $actionName = false,
        $idGoal = false,
        $revenue = false,
        $forceMatomoEndpoint = false
    ): string {
        $urlParams = ['idsite' => $idSite, 'rec' => 1];

        if ($actionName !== false) {
            $urlParams['action_name'] = urlencode(Common::unsanitizeInputValue($actionName));
        }

        if ($idGoal !== false) {
            $urlParams['idgoal'] = $idGoal;
            if ($revenue !== false) {
                $urlParams['revenue'] = $revenue;
            }
        }

        /**
         * Triggered when generating image link tracking code server side. Plugins can use
         * this event to customise the image tracking code that is displayed to the
         * user.
         *
         * @param string &$piwikHost The domain and URL path to the Matomo installation, eg,
         *                           `'examplepiwik.com/path/to/piwik'`.
         * @param array &$urlParams The query parameters used in the <img> element's src
         *                          URL. See Matomo's image tracking docs for more info.
         */
        Piwik::postEvent('SitesManager.getImageTrackingCode', [&$piwikUrl, &$urlParams]);

        $trackerCodeGenerator = new TrackerCodeGenerator();
        if ($forceMatomoEndpoint) {
            $trackerCodeGenerator->forceMatomoEndpoint();
        }
        $matomoPhp = $trackerCodeGenerator->getPhpTrackerEndpoint();

        $url = (ProxyHttp::isHttps() ? "https://" : "http://") . rtrim($piwikUrl, '/') . '/' . $matomoPhp . '?' . Url::getQueryStringFromParameters($urlParams);
        $html = "<!-- Matomo Image Tracker-->
<img referrerpolicy=\"no-referrer-when-downgrade\" src=\"" . htmlspecialchars($url, ENT_COMPAT, 'UTF-8') . "\" style=\"border:0\" alt=\"\" />
<!-- End Matomo -->";
        return htmlspecialchars($html, ENT_COMPAT, 'UTF-8');
    }

    /**
     * Returns all websites belonging to the specified group.
     *
     * Requires Superuser access.
     *
     * @param string $group Group name to filter by. An empty string returns websites without a group.
     * @return list<SiteData> Websites belonging to the group.
     */
    public function getSitesFromGroup(string $group = ''): array
    {
        Piwik::checkUserHasSuperUserAccess();

        $group = trim($group);
        $sites = $this->getModel()->getSitesFromGroup($group);

        foreach ($sites as &$site) {
            $this->enrichSite($site);
        }

        $sites = Site::setSitesFromArray($sites);
        return $sites;
    }

    /**
     * Returns the list of website groups, including the empty group
     * if no group was specified for some websites.
     *
     * Requires Superuser access.
     *
     * @return list<string> Distinct group names.
     */
    public function getSitesGroups(): array
    {
        Piwik::checkUserHasSuperUserAccess();

        $groups = $this->getModel()->getSitesGroups();
        $cleanedGroups = array_map('trim', $groups);

        return $cleanedGroups;
    }

    /**
     * Returns the website information for a single site: name, main_url, timezone, currency, etc.
     *
     * @param int $idSite The numeric ID of the website.
     * @return SiteData|array{} Site data array.
     */
    public function getSiteFromId(int $idSite): array
    {
        Piwik::checkUserHasViewAccess($idSite);

        $site = $this->getModel()->getSiteFromId($idSite);

        if ($site) {
            $this->enrichSite($site);
        }

        Site::setSiteFromArray($idSite, $site);

        return $site;
    }

    private function getModel(): Model
    {
        return new Model();
    }

    /**
     * Returns the list of all URLs registered for the given website (main_url + alias URLs).
     *
     * @param int $idSite The numeric ID of the website.
     * @return list<string> List of URLs, with the main URL first.
     */
    public function getSiteUrlsFromId(int $idSite): array
    {
        Piwik::checkUserHasViewAccess($idSite);
        return $this->getModel()->getSiteUrlsFromId($idSite);
    }

    /**
     * @return list<int>
     */
    private function getSitesId(): array
    {
        return $this->getModel()->getSitesId();
    }

    /**
     * Returns all websites.
     *
     * Requires Superuser access.
     *
     * @return array<int, SiteData> The list of websites, indexed by idsite.
     */
    public function getAllSites(): array
    {
        Piwik::checkUserHasSuperUserAccess();

        $sites  = $this->getModel()->getAllSites();
        $return = [];
        foreach ($sites as $site) {
            $this->enrichSite($site);
            $return[$site['idsite']] = $site;
        }

        $return = Site::setSitesFromArray($return);

        return $return;
    }

    /**
     * Returns the list of all the website IDs registered.
     *
     * Requires Superuser access.
     *
     * @return list<int> The list of website IDs.
     */
    public function getAllSitesId(): array
    {
        Piwik::checkUserHasSuperUserAccess();
        try {
            return $this->getSitesId();
        } catch (Exception $e) {
            // can be called before Matomo tables are created so return empty
            return [];
        }
    }

    /**
     * Returns the list of websites with the 'admin' access for the current user.
     * For the Superuser it returns all the websites in the database.
     *
     * @param bool $fetchAliasUrls Whether to include alias URLs as an `alias_urls` key in each site entry.
     * @param string|false $pattern Optional name/URL pattern to filter results by.
     * @param int|false $limit Maximum number of sites to return, or `false` for no limit.
     * @param int[] $sitesToExclude Site IDs to exclude from the result.
     * @return list<SiteData> Websites the current user has admin access to.
     */
    public function getSitesWithAdminAccess($fetchAliasUrls = false, $pattern = false, $limit = false, $sitesToExclude = []): array
    {
        $sitesId = $this->getSitesIdWithAdminAccess();

        // Remove the sites to exclude from the list of IDs.
        if (count($sitesToExclude)) {
            $sitesId = array_diff($sitesId, $sitesToExclude);
        }

        if ($pattern === false) {
            $sites = $this->getSitesFromIds($sitesId, $limit);
        } else {
            $sites = $this->getModel()->getPatternMatchSites($sitesId, $pattern, $limit ?: null);

            foreach ($sites as &$site) {
                $this->enrichSite($site);
            }

            $sites = Site::setSitesFromArray($sites);
        }

        if ($fetchAliasUrls) {
            foreach ($sites as &$site) {
                $site['alias_urls'] = $this->getSiteUrlsFromId((int) $site['idsite']);
            }
        }

        return $sites;
    }

    /**
     * Returns the list of websites where the current user has at least the provided access level.
     *
     * @param string $permission One of `'view'`, `'write'` or `'admin'`.
     * @param string|null $pattern Optional name/URL pattern to filter results by.
     * @param int|null $limit Maximum number of sites to return, or `null` for no limit.
     * @param int[] $sitesToExclude Site IDs to exclude from the result.
     * @param string[] $siteTypesToExclude Site types (e.g. `'website'`, `'mobileapp'`) to exclude from the result.
     * @return list<SiteData> Websites matching the access level and filters.
     */
    public function getSitesWithMinimumAccess(string $permission, ?string $pattern = null, ?int $limit = null, array $sitesToExclude = [], array $siteTypesToExclude = []): array
    {
        switch (strtolower($permission)) {
            case Access\Role\Admin::ID:
                $sitesId = Access::getInstance()->getSitesIdWithAdminAccess();
                break;
            case Access\Role\Write::ID:
                $sitesId = Access::getInstance()->getSitesIdWithAtLeastWriteAccess();
                break;
            case Access\Role\View::ID:
                $sitesId = Access::getInstance()->getSitesIdWithAtLeastViewAccess();
                break;
            default:
                throw new Exception('Invalid permission provided');
        }

        // Remove the sites to exclude from the list of IDs.
        if (count($sitesToExclude)) {
            $sitesId = array_diff($sitesId, $sitesToExclude);
        }

        if (empty($pattern)) {
            $sites = $this->getSitesFromIds($sitesId, $limit, $siteTypesToExclude);
        } else {
            $sites = $this->getModel()->getPatternMatchSites($sitesId, $pattern, $limit, $siteTypesToExclude);

            foreach ($sites as &$site) {
                $this->enrichSite($site);
            }

            $sites = Site::setSitesFromArray($sites);
        }

        return $sites;
    }

    /**
     * Returns the messages to warn users before deleting a site.
     *
     * @param int $idSite The numeric ID of the website to inspect.
     * @return list<string> Warning messages to display in the deletion confirmation dialog.
     * @internal
     * @unsanitized
     */
    public function getMessagesToWarnOnSiteRemoval(int $idSite): array
    {
        $messages = [];
        Piwik::checkUserHasSuperUserAccess();
        /**
         * Triggered before a modal to delete a measurable is displayed
         *
         * A plugin can listen to it and add additional information to be displayed in the measurable delete modal body
         *
         * @param array &$messages Additional messages to be shown in the delete measurable modal body
         * @param int $idSite The idSite to be deleted
         */
        Piwik::postEvent('SitesManager.getMessagesToWarnOnSiteRemoval', [&$messages, $idSite]);

        return $messages;
    }

    /**
     * Returns the list of websites with the 'view' access for the current user.
     *
     * For the Superuser this returns an empty array because the Superuser has admin access
     * on all websites. Use {@see getSitesWithAtLeastViewAccess()} instead.
     *
     * @return list<SiteData> Websites the current user has view-only access to.
     */
    public function getSitesWithViewAccess(): array
    {
        $sitesId = $this->getSitesIdWithViewAccess();
        return $this->getSitesFromIds($sitesId);
    }

    /**
     * Returns the list of websites with the 'view' or 'admin' access for the current user.
     * For the Superuser it returns all the websites in the database.
     *
     * @param int|false $limit Maximum number of sites to return, or `false` for no limit.
     * @param string|false $_restrictSitesToLogin Implementation detail for scheduled tasks where Superuser access
     *                                            is forced but sites should be restricted to a specific login. See #3017.
     * @return list<SiteData> Websites the current user has at least view access to.
     */
    public function getSitesWithAtLeastViewAccess($limit = false, $_restrictSitesToLogin = false): array
    {
        $sitesId = $this->getSitesIdWithAtLeastViewAccess($_restrictSitesToLogin);
        return $this->getSitesFromIds($sitesId, $limit);
    }

    /**
     * Returns the list of website IDs with the 'admin' access for the current user.
     * For the Superuser it returns all the website IDs in the database.
     *
     * @return list<int> Website IDs.
     */
    public function getSitesIdWithAdminAccess(): array
    {
        $sitesId = Access::getInstance()->getSitesIdWithAdminAccess();
        return $sitesId;
    }

    /**
     * Returns the list of website IDs with the 'view' access for the current user.
     *
     * For the Superuser this returns an empty array because the Superuser has admin access
     * on all websites. Use {@see getSitesIdWithAtLeastViewAccess()} instead.
     *
     * @return list<int> Website IDs.
     */
    public function getSitesIdWithViewAccess(): array
    {
        return Access::getInstance()->getSitesIdWithViewAccess();
    }

    /**
     * Returns the list of website IDs with the 'write' access for the current user.
     *
     * For the Superuser this returns an empty array because the Superuser has write access
     * on all websites. Use `getSitesIdWithAtLeastWriteAccess()` instead.
     *
     * @return list<int> Website IDs.
     */
    public function getSitesIdWithWriteAccess(): array
    {
        return Access::getInstance()->getSitesIdWithWriteAccess();
    }

    /**
     * Returns the list of website IDs with the 'view' or 'admin' access for the current user.
     * For the Superuser it returns all the website IDs in the database.
     *
     * @param string|false $_restrictSitesToLogin Implementation detail for scheduled tasks where Superuser access
     *                                            is forced but sites should be restricted to a specific login.
     * @return list<int> Website IDs.
     */
    public function getSitesIdWithAtLeastViewAccess($_restrictSitesToLogin = false): array
    {
        /** @var Scheduler $scheduler */
        $scheduler = StaticContainer::getContainer()->get('Piwik\Scheduler\Scheduler');

        if (Piwik::hasUserSuperUserAccess() && !$scheduler->isRunningTask()) {
            $sitesId = Access::getInstance()->getSitesIdWithAtLeastViewAccess();
            return array_map('intval', $sitesId);
        }

        if (
            !empty($_restrictSitesToLogin)
            // Only Superuser or logged in user can see viewable sites for a specific login,
            // but during scheduled task execution, we sometimes want to restrict sites to
            // a different login than the superuser.
            && (Piwik::hasUserSuperUserAccessOrIsTheUser($_restrictSitesToLogin)
                || $scheduler->isRunningTask())
        ) {
            if (Piwik::hasTheUserSuperUserAccess($_restrictSitesToLogin)) {
                return Access::getInstance()->getSitesIdWithAtLeastViewAccess();
            }

            $accessRaw = Access::getInstance()->getRawSitesWithSomeViewAccess($_restrictSitesToLogin);
            $sitesId = array_column($accessRaw, 'idsite');
            return array_map('intval', $sitesId);
        } else {
            return Access::getInstance()->getSitesIdWithAtLeastViewAccess();
        }
    }

    /**
     * Returns the list of websites from the ID array in parameters.
     * The user access is not checked in this method so the IDs must be accessible by the user.
     *
     * @param int[] $idSites Website IDs to fetch.
     * @param int|null|false $limit Maximum number of sites to return, or `false` for no limit.
     * @param string[] $siteTypesToExclude Site types to exclude from the result.
     * @return list<SiteData>
     */
    private function getSitesFromIds(array $idSites, $limit = false, array $siteTypesToExclude = []): array
    {
        $sites = $this->getModel()->getSitesFromIds($idSites, $limit, $siteTypesToExclude);

        foreach ($sites as &$site) {
            $this->enrichSite($site);
        }

        $sites = Site::setSitesFromArray($sites);

        return $sites;
    }

    /**
     * @return list<string>
     */
    protected function getNormalizedUrls(string $url): array
    {
        // if found, remove scheme and www. from URL
        $hostname = str_replace('www.', '', $url);
        $hostname = str_replace('http://', '', $hostname);
        $hostname = str_replace('https://', '', $hostname);

        // return all variations of the URL
        return [
            $url,
            "http://" . $hostname,
            "http://www." . $hostname,
            "https://" . $hostname,
            "https://www." . $hostname,
        ];
    }

    /**
     * Returns the list of website IDs associated with a URL.
     *
     * Matches against main URLs and alias URLs. Automatically tries HTTP/HTTPS and www/non-www
     * variants. Only websites the current user has access to are returned (all websites for Superusers).
     *
     * @param string $url The URL to search for.
     * @return list<array{idsite: string}> Matching website IDs.
     */
    public function getSitesIdFromSiteUrl(string $url): array
    {
        $url = $this->removeTrailingSlash($url);
        $normalisedUrls = $this->getNormalizedUrls($url);

        if (Piwik::hasUserSuperUserAccess()) {
            $ids   = $this->getModel()->getAllSitesIdFromSiteUrl($normalisedUrls);
        } else {
            $login = Piwik::getCurrentUserLogin();
            $ids   = $this->getModel()->getSitesIdFromSiteUrlHavingAccess($login, $normalisedUrls);
        }

        return $ids;
    }

    /**
     * Returns all website IDs with a timezone matching one of the specified timezones.
     *
     * Requires Superuser access.
     *
     * @param string[]|string $timezones Timezone identifiers to filter by (array or comma-separated string).
     * @return list<int> Matching website IDs.
     * @ignore
     */
    public function getSitesIdFromTimezones($timezones): array
    {
        Piwik::checkUserHasSuperUserAccess();

        $timezones = Piwik::getArrayFromApiParameter($timezones);
        $timezones = array_unique($timezones);

        $ids = $this->getModel()->getSitesFromTimezones($timezones);

        $return = [];
        foreach ($ids as $id) {
            $return[] = (int)$id['idsite'];
        }

        return $return;
    }

    /**
     * @param array<string, mixed> $site
     */
    private function enrichSite(array &$site): void
    {
        $cacheKey = $site['timezone'] . $this->translator->getCurrentLanguage();
        if (!isset($this->timezoneNameCache[$cacheKey])) {
            //cached as this can be called VERY often and getTimezoneName is quite slow
            $this->timezoneNameCache[$cacheKey] = $this->getTimezoneName($site['timezone']);
        }
        $site['timezone_name'] = $this->timezoneNameCache[$cacheKey];

        $key = 'Intl_Currency_' . $site['currency'];
        $name = $this->translator->translate($key);

        $site['currency_name'] = ($key === $name) ? $site['currency'] : $name;

        // don't want to expose other user logins here
        if (!Piwik::hasUserSuperUserAccess()) {
            unset($site['creator_login']);
        }
    }

    /**
     * Add a website.
     *
     * Requires Superuser access.
     *
     * The website is defined by a name and an array of URLs.
     *
     * @param string $siteName Site name.
     * @param string[]|string|null $urls The URLs array must contain at least one URL called the 'main_url';
     *                                   if several URLs are provided in the array, they will be recorded
     *                                   as alias URLs for this website.
     *                                   When calling the API via HTTP specify multiple URLs via `&urls[]=http...&urls[]=http...`.
     * @param int|null $ecommerce Whether Ecommerce Reporting is enabled (1) or disabled (0).
     * @param int|null $siteSearch Whether Site Search is enabled (1) or disabled (0).
     * @param string|null $searchKeywordParameters Comma-separated list of search keyword parameter names.
     * @param string|null $searchCategoryParameters Comma-separated list of search category parameter names.
     * @param string|null $excludedIps Comma-separated list of IPs to exclude from reports (allows wildcards).
     * @param string|null $excludedQueryParameters Comma-separated list of URL query parameters to strip from tracked URLs.
     * @param string|null $timezone Timezone string, e.g. `'Europe/London'`. Defaults to the global default timezone.
     * @param string|null $currency Currency code, e.g. `'EUR'`. Defaults to the global default currency.
     * @param string|null $group Website group identifier. Requires Superuser access.
     * @param string|null $startDate Date at which statistics for this website will start, in `YYYY-MM-DD` format.
     *                               Defaults to today's date.
     * @param string|null $excludedUserAgents Comma-separated list of user agent substrings to exclude.
     * @param int|null $keepURLFragments If `1`, URL fragments will be kept when tracking. If `2`, they
     *                                   will be removed. If `0`, the default global behavior will be used.
     * @param string|null $type The website type, defaults to `'website'` if not set.
     * @param SettingValues|null $settingValues Measurable settings keyed by plugin name, each containing an array
     *                                         of `['name' => string, 'value' => mixed]` pairs.
     * @param bool|null $excludeUnknownUrls Whether to track only URLs matching one of the website's registered URLs.
     * @param string|null $excludedReferrers Comma-separated list of hosts/URLs to exclude from referrer detection.
     * @param string|null $description Optional description providing additional context about this site, such as its
     *                                 purpose or usage.
     * @return int The ID of the newly created website.
     * @see getKeepURLFragmentsGlobal
     */
    public function addSite(
        string $siteName,
        $urls = null,
        $ecommerce = null,
        $siteSearch = null,
        $searchKeywordParameters = null,
        $searchCategoryParameters = null,
        $excludedIps = null,
        $excludedQueryParameters = null,
        $timezone = null,
        $currency = null,
        $group = null,
        $startDate = null,
        $excludedUserAgents = null,
        $keepURLFragments = null,
        $type = null,
        $settingValues = null,
        $excludeUnknownUrls = null,
        $excludedReferrers = null,
        $description = null
    ): int {
        Piwik::checkUserHasSuperUserAccess();
        SitesManager::dieIfSitesAdminIsDisabled();

        $this->checkName($siteName);

        if (!isset($settingValues)) {
            $settingValues = [];
        }

        $coreProperties = [];
        $coreProperties = $this->setSettingValue('urls', $urls, $coreProperties, $settingValues);
        $coreProperties = $this->setSettingValue('ecommerce', $ecommerce, $coreProperties, $settingValues);
        $coreProperties = $this->setSettingValue('group', $group, $coreProperties, $settingValues);
        $coreProperties = $this->setSettingValue('sitesearch', $siteSearch, $coreProperties, $settingValues);
        $coreProperties = $this->setSettingValue('sitesearch_keyword_parameters', explode(',', $searchKeywordParameters ?? ''), $coreProperties, $settingValues);
        $coreProperties = $this->setSettingValue('sitesearch_category_parameters', explode(',', $searchCategoryParameters ?? ''), $coreProperties, $settingValues);
        $coreProperties = $this->setSettingValue('keep_url_fragment', $keepURLFragments, $coreProperties, $settingValues);
        $coreProperties = $this->setSettingValue('exclude_unknown_urls', $excludeUnknownUrls, $coreProperties, $settingValues);
        $coreProperties = $this->setSettingValue('excluded_ips', explode(',', $excludedIps ?? ''), $coreProperties, $settingValues);
        $coreProperties = $this->setSettingValue('excluded_parameters', explode(',', $excludedQueryParameters ?? ''), $coreProperties, $settingValues);
        $coreProperties = $this->setSettingValue('excluded_user_agents', explode(',', $excludedUserAgents ?? ''), $coreProperties, $settingValues);
        $coreProperties = $this->setSettingValue('excluded_referrers', explode(',', $excludedReferrers ?? ''), $coreProperties, $settingValues);

        $timezone = trim($timezone ?? '');
        if (empty($timezone)) {
            $timezone = $this->getDefaultTimezone();
        }
        $this->checkValidTimezone($timezone);

        if (empty($currency)) {
            $currency = $this->getDefaultCurrency();
        }
        $this->checkValidCurrency($currency);

        $bind = ['name' => $siteName];
        $bind['description'] = $this->checkAndReturnDescription($description);
        $bind['timezone']   = $timezone;
        $bind['currency']   = $currency;
        $bind['main_url']   = '';

        if (is_null($startDate)) {
            $bind['ts_created'] = Date::now()->getDatetime();
        } else {
            $bind['ts_created'] = Date::factory($startDate)->getDatetime();
        }

        $bind['type'] = $this->checkAndReturnType($type);

        if (!empty($group) && Piwik::hasUserSuperUserAccess()) {
            $bind['group'] = trim($group);
        } else {
            $bind['group'] = "";
        }

        $bind['creator_login'] = Piwik::getCurrentUserLogin();

        $allSettings = $this->setAndValidateMeasurableSettings(0, 'website', $coreProperties);

        // any setting specified in setting values will overwrite other setting
        if (!empty($settingValues)) {
            $this->setAndValidateMeasurableSettings(0, $bind['type'], $settingValues);
        }

        foreach ($allSettings as $settings) {
            foreach ($settings->getSettingsWritableByCurrentUser() as $setting) {
                $name = $setting->getName();
                if ($setting instanceof MeasurableProperty && $name !== 'urls') {
                    $default = $setting->getDefaultValue();
                    if (is_bool($default)) {
                        $default = (int) $default;
                    } elseif (is_array($default)) {
                        $default = implode(',', $default);
                    }

                    $bind[$name] = $default;
                }
            }
        }

        $idSite = $this->getModel()->createSite($bind);

        if (!empty($coreProperties)) {
            $this->saveMeasurableSettings($idSite, 'website', $coreProperties);
        }
        if (!empty($settingValues)) {
            $this->saveMeasurableSettings($idSite, $bind['type'], $settingValues);
        }

        // we reload the access list which doesn't yet take in consideration this new website
        Access::getInstance()->reloadAccess();

        $this->postUpdateWebsite($idSite);

        /**
         * Triggered after a site has been added.
         *
         * @param int $idSite The ID of the site that was added.
         */
        Piwik::postEvent('SitesManager.addSite.end', [$idSite]);

        return (int) $idSite;
    }

    /**
     * @param string|int|float|bool|list<mixed>|null $value
     * @param SettingValues $coreProperties
     * @param SettingValues $settingValues
     * @return SettingValues
     */
    private function setSettingValue(string $fieldName, $value, array $coreProperties, array $settingValues): array
    {
        $pluginName = 'WebsiteMeasurable';

        if (isset($value)) {
            if (empty($coreProperties[$pluginName])) {
                $coreProperties[$pluginName] = [];
            }

            $coreProperties[$pluginName][] = ['name' => $fieldName, 'value' => $value];
        } elseif (!empty($settingValues[$pluginName])) {
            // we check if the value is defined in the setting values instead
            foreach ($settingValues[$pluginName] as $key => $setting) {
                if ($setting['name'] === $fieldName) {
                    if (empty($coreProperties[$pluginName])) {
                        $coreProperties[$pluginName] = [];
                    }

                    $coreProperties[$pluginName][] = ['name' => $fieldName, 'value' => $setting['value']];
                    return $coreProperties;
                }
            }
        }

        return $coreProperties;
    }

    /**
     * Returns the editable measurable settings metadata for a website.
     *
     * Requires admin access to the specified site.
     *
     * @param int $idSite The numeric ID of the website to inspect.
     * @return list<array{pluginName: string, title: string, settings: list<mixed>}> Formatted measurable settings grouped by plugin, each containing setting definitions and values.
     */
    public function getSiteSettings(int $idSite): array
    {
        Piwik::checkUserHasAdminAccess($idSite);

        $measurableSettings = $this->settingsProvider->getAllMeasurableSettings($idSite);

        return $this->settingsMetadata->formatSettings($measurableSettings, $idSite);
    }

    /**
     * @param SettingValues $settingValues
     * @return array<string, \Piwik\Settings\Measurable\MeasurableSettings>
     */
    private function setAndValidateMeasurableSettings(int $idSite, ?string $idType, array $settingValues): array
    {
        $measurableSettings = $this->settingsProvider->getAllMeasurableSettings($idSite, $idType);

        $this->settingsMetadata->setPluginSettings($measurableSettings, $settingValues);

        return $measurableSettings;
    }

    /**
     * @param SettingValues $settingValues
     */
    private function saveMeasurableSettings(int $idSite, ?string $idType, array $settingValues): void
    {
        $measurableSettings = $this->setAndValidateMeasurableSettings($idSite, $idType, $settingValues);

        foreach ($measurableSettings as $measurableSetting) {
            $measurableSetting->save();
        }
    }

    private function postUpdateWebsite(int $idSite): void
    {
        Site::clearCache();
        Cache::regenerateCacheWebsiteAttributes($idSite);
        Cache::clearCacheGeneral();
        SiteUrls::clearSitesCache();
    }

    /**
     * Delete a website from the database, given its ID.
     *
     * The method deletes the site record and associated configuration (goals, segments, etc.)
     * but does **not** delete any logs or archives that belong to this website. You can delete
     * logs and archives for a site manually as described in this
     * FAQ: https://matomo.org/faq/how-to/faq_73/ .
     *
     * At least one website must remain in the database; deleting the last website is not allowed.
     *
     * Requires Superuser access.
     *
     * @param int $idSite The numeric ID of the website to delete.
     * @param string|null $passwordConfirmation The current user's password. Only required when the request
     *                                          is authenticated with a session token.
     */
    public function deleteSite(
        int $idSite,
        #[\SensitiveParameter]
        ?string $passwordConfirmation = null
    ): void {
        Piwik::checkUserHasSuperUserAccess();
        SitesManager::dieIfSitesAdminIsDisabled();

        if (StaticContainer::get(AuthenticationToken::class)->isSessionToken()) {
            $this->confirmCurrentUserPassword($passwordConfirmation);
        }

        $lock = new Lock(StaticContainer::get(LockBackend::class), 'SitesManager.deleteSite');
        // we use the same lock id for all requests to ensure only one site is removed at a time and the check for one remaining site can't be bypassed
        $lock->execute('delete', function () use ($idSite) {
            $idSites = $this->getSitesId();
            if (!in_array($idSite, $idSites)) {
                throw new Exception("website id = $idSite not found");
            }
            $nbSites = count($idSites);
            if ($nbSites == 1) {
                throw new Exception($this->translator->translate("SitesManager_ExceptionDeleteSite"));
            }

            $this->getModel()->deleteSite($idSite);
            Site::clearCacheForSite($idSite);

            $coreModel = new CoreModel();
            $coreModel->deleteInvalidationsForSites([$idSite]);

            /**
             * Triggered after a site has been deleted.
             *
             * Plugins can use this event to remove site specific values or settings, such as removing all
             * goals that belong to a specific website. If you store any data related to a website you
             * should clean up that information here.
             *
             * @param int $idSite The ID of the site being deleted.
             */
            Piwik::postEvent('SitesManager.deleteSite.end', [$idSite]);
        });
    }

    private function checkValidTimezone(string $timezone): bool
    {
        try {
            Date::factory('today', $timezone);
        } catch (\Exception $e) {
            throw new Exception($this->translator->translate('SitesManager_ExceptionInvalidTimezone', [$timezone]));
        }

        $timezones = DateTimeZone::listIdentifiers(DateTimeZone::ALL_WITH_BC);
        $timezones = array_merge($timezones, array_keys($this->getTimezonesListUTCOffsets()));
        if (in_array($timezone, $timezones)) {
            return true;
        }
        throw new Exception($this->translator->translate('SitesManager_ExceptionInvalidTimezone', [$timezone]));
    }

    private function checkValidCurrency(string $currency): void
    {
        if (!in_array($currency, array_keys($this->getCurrencyList()))) {
            throw new Exception($this->translator->translate('SitesManager_ExceptionInvalidCurrency', [$currency, "USD, EUR, etc."]));
        }
    }

    private function checkAndReturnType(?string $type): string
    {
        if (empty($type)) {
            $type = Site::DEFAULT_SITE_TYPE;
        }

        if (!is_string($type) || !$this->typeManager->isExistingType($type)) {
            throw new Exception("Invalid website type $type");
        }

        return $type;
    }

    /**
     * Checks that the submitted IPs (comma separated list) are valid
     * Returns the cleaned up IPs
     *
     * @param string $excludedIps Comma separated list of IP addresses
     * @return string Comma separated list of IP addresses
     */
    private function checkAndReturnExcludedIps(string $excludedIps): string
    {
        if (empty($excludedIps)) {
            return '';
        }

        $ips = explode(',', $excludedIps);
        $ips = array_map('trim', $ips);
        $ips = array_filter($ips, 'strlen');

        foreach ($ips as $ip) {
            if (!$this->isValidIp($ip)) {
                throw new Exception(
                    $this->translator->translate(
                        'SitesManager_ExceptionInvalidIPFormat',
                        [$ip, "1.2.3.4, 1.2.3.*, or 1.2.3.4/5"]
                    )
                );
            }
        }

        return implode(',', $ips);
    }

    /**
     * Add a list of alias URLs to the given website.
     *
     * If some URLs given in parameter are already recorded as alias URLs for this website,
     * they won't be duplicated. The 'main_url' of the website won't be affected by this method.
     *
     * Requires admin access to the specified site.
     *
     * @param int $idSite The numeric ID of the website to update.
     * @param string[]|string $urls URLs to add. When calling the API via HTTP specify multiple URLs
     *                              via `&urls[]=http...&urls[]=http...`.
     * @return int The number of newly inserted URLs.
     */
    public function addSiteAliasUrls(int $idSite, $urls): int
    {
        Piwik::checkUserHasAdminAccess($idSite);

        if (empty($urls)) {
            return 0;
        }

        if (!is_array($urls)) {
            $urls = [$urls];
        }

        $urlsInit = $this->getSiteUrlsFromId($idSite);
        $toInsert = array_merge($urlsInit, $urls);

        $urlsProperty = new Urls($idSite);
        $urlsProperty->setValue($toInsert);
        $urlsProperty->save();

        $inserted = array_diff($urlsProperty->getValue(), $urlsInit);

        $this->postUpdateWebsite($idSite);

        return count($inserted);
    }

    /**
     * Set the list of alias URLs for the given website.
     *
     * Completely overwrites the current list of alias URLs with the provided list.
     * The 'main_url' of the website won't be affected by this method.
     *
     * Requires admin access to the specified site.
     *
     * @param int $idSite The numeric ID of the website to update.
     * @param string[] $urls Alias URLs to persist for the website.
     * @return int The number of newly inserted URLs.
     */
    public function setSiteAliasUrls(int $idSite, array $urls = []): int
    {
        Piwik::checkUserHasAdminAccess($idSite);

        $mainUrl = Site::getMainUrlFor($idSite);
        array_unshift($urls, $mainUrl);

        $urlsProperty = new Urls($idSite);
        $urlsProperty->setValue($urls);
        $urlsProperty->save();

        $inserted = array_diff($urlsProperty->getValue(), $urls);

        $this->postUpdateWebsite($idSite);

        return count($inserted);
    }

    /**
     * Get the start and end IP addresses for an IP address range.
     *
     * @param string $ipRange IP address range in presentation format (e.g. `'192.168.1.0/24'`).
     * @return array{0: string, 1: string}|false Low and high IP addresses in presentation format, or `false` on error.
     */
    public function getIpsForRange(string $ipRange)
    {
        $range = IPUtils::getIPRangeBounds($ipRange);
        if ($range === null) {
            return false;
        }

        return [IPUtils::binaryToStringIP($range[0]), IPUtils::binaryToStringIP($range[1])];
    }

    /**
     * Sets IPs to be excluded from all websites. IPs can contain wildcards.
     * Will also apply to websites created in the future.
     *
     * Requires Superuser access.
     *
     * @param string $excludedIps Comma-separated list of IPs to exclude from being tracked (allows wildcards).
     * @return bool Always `true`.
     */
    public function setGlobalExcludedIps(string $excludedIps): bool
    {
        Piwik::checkUserHasSuperUserAccess();
        $excludedIps = $this->checkAndReturnExcludedIps($excludedIps);
        Option::set(self::OPTION_EXCLUDED_IPS_GLOBAL, $excludedIps);
        Cache::deleteTrackerCache();
        return true;
    }

    /**
     * Sets global Site Search keyword/category parameter names, to be used on websites
     * which have not specified these values individually.
     *
     * Requires Superuser access.
     *
     * @param string $searchKeywordParameters Comma-separated site search keyword parameter names.
     * @param string $searchCategoryParameters Comma-separated site search category parameter names.
     * @return bool Always `true`.
     */
    public function setGlobalSearchParameters(string $searchKeywordParameters, string $searchCategoryParameters): bool
    {
        Piwik::checkUserHasSuperUserAccess();
        Option::set(self::OPTION_SEARCH_KEYWORD_QUERY_PARAMETERS_GLOBAL, $searchKeywordParameters);
        Option::set(self::OPTION_SEARCH_CATEGORY_QUERY_PARAMETERS_GLOBAL, $searchCategoryParameters);
        Cache::deleteTrackerCache();
        return true;
    }

    /**
     * Returns the global site search keyword parameter names.
     *
     * Falls back to the built-in default list when no custom parameters have been configured.
     *
     * @return string Comma-separated list of keyword parameter names.
     */
    public function getSearchKeywordParametersGlobal(): string
    {
        Piwik::checkUserHasSomeAdminAccess();
        $names = Option::get(self::OPTION_SEARCH_KEYWORD_QUERY_PARAMETERS_GLOBAL);
        if ($names === false) {
            $names = self::DEFAULT_SEARCH_KEYWORD_PARAMETERS;
        }
        if (empty($names)) {
            $names = '';
        }
        return $names;
    }

    /**
     * Returns the global site search category parameter names.
     *
     * @return string|false Comma-separated list of URL parameter names, or `false` if not configured.
     */
    public function getSearchCategoryParametersGlobal()
    {
        Piwik::checkUserHasSomeAdminAccess();
        return Option::get(self::OPTION_SEARCH_CATEGORY_QUERY_PARAMETERS_GLOBAL);
    }


    /**
     * Returns the list of URL query parameters that are excluded for the given website
     *
     * Globally excluded parameters are included in this list
     *
     * @param int $idSite The numeric ID of the website to inspect.
     * @return string[] List of excluded query parameter names for the requested website.
     */
    public function getExcludedQueryParameters(int $idSite): array
    {
        $site = $this->getSiteFromId($idSite);

        try {
            return SitesManager::getTrackerExcludedQueryParameters($site);
        } catch (Exception $e) {
            // an exception is thrown when the user has no view access.
            // do not throw the exception to the outside.
            return [];
        }
    }

    /**
     * Returns the list of URL query parameters that are excluded from all websites.
     *
     * The result depends on the current exclusion type: for "common_session_parameters" an empty
     * string is returned, for "matomo_recommended_pii" the built-in PII parameters are returned,
     * and for "custom" the user-configured parameters are returned.
     *
     * @param int|null $idSite Optional site ID for site-specific filtering.
     * @return string Comma-separated list of URL parameters.
     */
    public function getExcludedQueryParametersGlobal(?int $idSite = null): string
    {
        Piwik::checkUserHasSomeViewAccess();

        switch ($this->getExclusionTypeForQueryParams($idSite)) {
            case SitesManager::URL_PARAM_EXCLUSION_TYPE_NAME_COMMON_SESSION_PARAMETERS:
                return '';
            case SitesManager::URL_PARAM_EXCLUSION_TYPE_NAME_MATOMO_RECOMMENDED_PII:
                return implode(',', Config::getInstance()->SitesManager['CommonPIIParams']);
            default:
                return Option::get(self::OPTION_EXCLUDED_QUERY_PARAMETERS_GLOBAL) ?: '';
        }
    }

    /**
     * Returns the list of user agent substrings to look for when excluding visits for
     * all websites. If a visitor's user agent string contains one of these substrings,
     * their visits will not be included.
     *
     * @return string|false Comma separated list of strings.
     */
    public function getExcludedUserAgentsGlobal()
    {
        Piwik::checkUserHasSomeAdminAccess();
        return Option::get(self::OPTION_EXCLUDED_USER_AGENTS_GLOBAL);
    }

    /**
     * Sets list of user agent substrings to look for when excluding visits.
     *
     * Requires Superuser access.
     *
     * @param string $excludedUserAgents Comma-separated list of strings. Each element is trimmed,
     *                                   and empty strings are removed.
     * @see getExcludedUserAgentsGlobal
     */
    public function setGlobalExcludedUserAgents(string $excludedUserAgents): void
    {
        Piwik::checkUserHasSuperUserAccess();

        // update option
        $excludedUserAgents = $this->checkAndReturnCommaSeparatedStringList($excludedUserAgents);
        Option::set(self::OPTION_EXCLUDED_USER_AGENTS_GLOBAL, $excludedUserAgents);

        // make sure tracker cache will reflect change
        Cache::deleteTrackerCache();
    }

    /**
     * Returns the list of URLs/hosts that should be ignored when detecting referrers for the given site.
     *
     * This includes both site-specific and global excluded referrers.
     *
     * @param int $idSite The numeric ID of the website to inspect.
     * @return list<string> Excluded referrer URLs/hosts.
     */
    public function getExcludedReferrers(int $idSite): array
    {
        Piwik::checkUserHasViewAccess($idSite);

        try {
            $attributes = Cache::getCacheWebsiteAttributes($idSite);

            if (isset($attributes['excluded_referrers'])) {
                return $attributes['excluded_referrers'];
            }
        } catch (UnexpectedWebsiteFoundException $e) {
            $cached = Cache::getCacheGeneral();
            if (isset($cached['global_excluded_referrers'])) {
                return $cached['global_excluded_referrers'];
            }
        }

        return [];
    }

    /**
     * Returns the global list of URLs/hosts that should be ignored when detecting referrers.
     *
     * If a visitor is coming from a site on this list, it will be treated as a direct entry.
     *
     * @return string Comma-separated list of excluded referrer URLs/hosts.
     */
    public function getExcludedReferrersGlobal(): string
    {
        Piwik::checkUserHasSomeAdminAccess();
        $exclusion = Option::get(self::OPTION_EXCLUDED_REFERRERS_GLOBAL);

        return is_string($exclusion) ? $exclusion : '';
    }

    /**
     * Sets list of URLs/hosts that should be ignored when detecting referrers.
     *
     * Requires Superuser access. URLs can be specified fully qualified (`http://example.com/path`),
     * without protocol (`example.com/path`), or with subdomain wildcard (`.example.com`).
     *
     * @param string $excludedReferrers Comma-separated list of URLs/hosts. Each element is trimmed,
     *                                  and empty strings are removed.
     * @see getExcludedReferrersGlobal
     */
    public function setGlobalExcludedReferrers(string $excludedReferrers): void
    {
        Piwik::checkUserHasSuperUserAccess();

        $excludedUrls = $this->checkAndReturnCommaSeparatedStringList($excludedReferrers);

        foreach (!empty($excludedUrls) ? explode(',', $excludedUrls) : [] as $url) {
            // We allow urls to be provided:
            // - fully qualified like http://example.url/path
            // - without protocol like example.url/path
            // - with subdomain wildcard like .example.url/path
            $prefixedUrl = 'https://' . ltrim(preg_replace('/^https?:\/\//', '', $url) ?? '', '.');
            $parsedUrl = @parse_url($prefixedUrl);
            if (false === $parsedUrl || !UrlHelper::isLookLikeUrl($prefixedUrl)) {
                throw new Exception(Piwik::translate('SitesManager_ExceptionInvalidUrl', [$url]));
            }
        }

        // update option
        Option::set(self::OPTION_EXCLUDED_REFERRERS_GLOBAL, $excludedUrls);

        // make sure tracker cache will reflect change
        Cache::deleteTrackerCache();
    }

    /**
     * Returns whether the default global behavior is to keep URL fragments when tracking.
     *
     * @return bool `true` if URL fragments are kept by default, `false` if they are removed.
     */
    public function getKeepURLFragmentsGlobal(): bool
    {
        Piwik::checkUserHasSomeViewAccess();
        return (bool)Option::get(self::OPTION_KEEP_URL_FRAGMENTS_GLOBAL);
    }

    /**
     * Sets whether the default behavior should be to keep URL fragments when
     * tracking or not.
     *
     * Requires Superuser access.
     *
     * @param bool $enabled If `true`, the default behavior will be to keep URL
     *                      fragments when tracking. If `false`, they will be removed.
     */
    public function setKeepURLFragmentsGlobal($enabled): void
    {
        Piwik::checkUserHasSuperUserAccess();

        // update option
        Option::set(self::OPTION_KEEP_URL_FRAGMENTS_GLOBAL, (string)(int)$enabled);

        // make sure tracker cache will reflect change
        Cache::deleteTrackerCache();
    }

    /**
     * Sets list of URL query parameters to be excluded on all websites.
     * Will also apply to websites created in the future.
     *
     * Requires Superuser access.
     *
     * @param string $excludedQueryParameters Comma-separated list of URL query parameters to exclude from URLs.
     * @return bool Always `true`.
     * @deprecated Use {@see setGlobalQueryParamExclusion()} instead.
     */
    public function setGlobalExcludedQueryParameters($excludedQueryParameters): bool
    {
        if (empty($excludedQueryParameters)) {
            $this->setGlobalQueryParamExclusion(SitesManager::URL_PARAM_EXCLUSION_TYPE_NAME_COMMON_SESSION_PARAMETERS);
            return true;
        }
        $this->setGlobalQueryParamExclusion(
            SitesManager::URL_PARAM_EXCLUSION_TYPE_NAME_CUSTOM,
            $excludedQueryParameters
        );
        return true;
    }

    /**
     * Returns the list of IPs that are excluded from all websites
     *
     * @return string|false Comma separated list of IPs
     */
    public function getExcludedIpsGlobal()
    {
        Piwik::checkUserHasSomeAdminAccess();
        return Option::get(self::OPTION_EXCLUDED_IPS_GLOBAL);
    }

    /**
     * Returns the default currency that will be set when creating a website through the API.
     *
     * @return string Currency code, e.g. `'USD'`.
     */
    public function getDefaultCurrency(): string
    {
        Piwik::checkUserHasSomeAdminAccess();
        $defaultCurrency = Option::get(self::OPTION_DEFAULT_CURRENCY);
        if ($defaultCurrency) {
            return $defaultCurrency;
        }
        return 'USD';
    }

    /**
     * Sets the default currency that will be used when creating websites.
     *
     * Requires Superuser access.
     *
     * @param string $defaultCurrency Currency code, e.g. `'USD'`.
     * @return bool Always `true`.
     */
    public function setDefaultCurrency(string $defaultCurrency): bool
    {
        Piwik::checkUserHasSuperUserAccess();
        $this->checkValidCurrency($defaultCurrency);
        Option::set(self::OPTION_DEFAULT_CURRENCY, $defaultCurrency);
        return true;
    }

    /**
     * Returns the default timezone that will be set when creating a website through the API.
     * In the UI, if the default timezone is not UTC, it will be pre-selected in the drop-down.
     *
     * @return string Timezone identifier, e.g. `'UTC+7'` or `'Europe/Paris'`.
     */
    public function getDefaultTimezone(): string
    {
        $defaultTimezone = Option::get(self::OPTION_DEFAULT_TIMEZONE);
        if ($defaultTimezone) {
            return $defaultTimezone;
        }
        return 'UTC';
    }

    /**
     * Sets the default timezone that will be used when creating websites.
     *
     * Requires Superuser access.
     *
     * @param string $defaultTimezone Timezone string, e.g. `'Europe/Paris'` or `'UTC+8'`.
     * @return bool Always `true`.
     */
    public function setDefaultTimezone(string $defaultTimezone): bool
    {
        Piwik::checkUserHasSuperUserAccess();
        $this->checkValidTimezone($defaultTimezone);
        Option::set(self::OPTION_DEFAULT_TIMEZONE, $defaultTimezone);
        return true;
    }

    /**
     * Sets global query parameter exclusion based on the specified exclusion type.
     *
     * Requires Superuser access.
     *
     * @param string $exclusionType The type of query parameter exclusion. One of:
     *                              - `'common_session_parameters'` — strip common session parameters only.
     *                              - `'matomo_recommended_pii'` — strip Matomo's recommended PII parameters.
     *                              - `'custom'` — strip a user-defined list of parameters.
     * @param string|null $queryParamsToExclude Comma-separated list of query parameters to exclude.
     *                                         Required when `$exclusionType` is `'custom'`, ignored otherwise.
     */
    public function setGlobalQueryParamExclusion(string $exclusionType, ?string $queryParamsToExclude = null): void
    {
        Piwik::checkUserHasSuperUserAccess();

        $queryParamsToExclude = $this->checkAndReturnCommaSeparatedStringList($queryParamsToExclude ?? '');
        $whiteListValidator = new WhitelistedValue(SitesManager::URL_PARAM_EXCLUSION_TYPES);
        $whiteListValidator->validate($exclusionType);

        if ($exclusionType === SitesManager::URL_PARAM_EXCLUSION_TYPE_NAME_CUSTOM && empty($queryParamsToExclude)) {
            throw new Exception($this->translator->translate('SitesManager_ExceptionEmptyQueryParamsForCustomType'));
        }

        if ($exclusionType !== SitesManager::URL_PARAM_EXCLUSION_TYPE_NAME_CUSTOM && !empty($queryParamsToExclude)) {
            throw new Exception($this->translator->translate('SitesManager_ExceptionNonEmptyQueryParamsForNonCustomType'));
        }

        Option::set(self::OPTION_EXCLUDE_TYPE_QUERY_PARAMS_GLOBAL, $exclusionType);

        if ($exclusionType !== SitesManager::URL_PARAM_EXCLUSION_TYPE_NAME_CUSTOM) {
            Option::delete(self::OPTION_EXCLUDED_QUERY_PARAMETERS_GLOBAL);
            Cache::deleteTrackerCache();
            return;
        }

        Option::set(self::OPTION_EXCLUDED_QUERY_PARAMETERS_GLOBAL, $queryParamsToExclude);
        Cache::deleteTrackerCache();
    }

    /**
     * Gets the exclusion type, if the option is not present in the store then it infers the type based on if there are
     * custom exclusions already defined.
     *
     * @param int|null $idSite Specific site ID when site-specific filtering is active, or `null` for global settings.
     * @return string Query parameter exclusion type name.
     */
    public function getExclusionTypeForQueryParams(?int $idSite = null): string
    {
        Piwik::checkUserHasSomeViewAccess();

        $result = FilterPIIParameters::getInstance($idSite)->getValue();
        if (!empty($result)) {
            return $result;
        }

        $excludedQueryParamsGlobal = Option::get(self::OPTION_EXCLUDED_QUERY_PARAMETERS_GLOBAL);
        if (empty($excludedQueryParamsGlobal)) {
            return SitesManager::URL_PARAM_EXCLUSION_TYPE_NAME_COMMON_SESSION_PARAMETERS;
        }

        return SitesManager::URL_PARAM_EXCLUSION_TYPE_NAME_CUSTOM;
    }

    /**
     * Update an existing website.
     *
     * If only one URL is specified then only the main URL will be updated.
     * If several URLs are specified, both the main URL and the alias URLs will be updated.
     * Any parameter set to `null` will leave the existing value unchanged.
     *
     * Requires admin access to the specified site.
     *
     * @param int $idSite Website ID defining the website to edit.
     * @param string|null $siteName Website name.
     * @param string[]|string|null $urls The website URLs.
     *                                   When calling the API via HTTP specify multiple URLs via `&urls[]=http...&urls[]=http...`.
     * @param int|null $ecommerce Whether Ecommerce is enabled (`1`) or disabled (`0`).
     * @param int|null $siteSearch Whether Site Search is enabled (`1`) or disabled (`0`).
     * @param string|null $searchKeywordParameters Comma-separated list of search keyword parameter names.
     * @param string|null $searchCategoryParameters Comma-separated list of search category parameter names.
     * @param string|null $excludedIps Comma-separated list of IPs to exclude from being tracked (allows wildcards).
     * @param string|null $excludedQueryParameters Comma-separated list of URL query parameters to strip from tracked URLs.
     * @param string|null $timezone Timezone string, e.g. `'Europe/London'`.
     * @param string|null $currency Currency code, e.g. `'EUR'`.
     * @param string|null $group Group name where this website belongs. Requires Superuser access.
     * @param string|null $startDate Date at which statistics for this website will start, in `YYYY-MM-DD` format.
     * @param string|null $excludedUserAgents Comma-separated list of user agent substrings to exclude.
     * @param int|null $keepURLFragments If `1`, URL fragments will be kept when tracking. If `2`, they will be
     *                                   removed. If `0`, the default global behavior will be used.
     * @param string|null $type The website type, e.g. `'website'`.
     * @param SettingValues|null $settingValues Measurable settings keyed by plugin name, each containing an array
     *                                         of `['name' => string, 'value' => mixed]` pairs.
     * @param bool|null $excludeUnknownUrls Whether to track only URLs matching one of the website's registered URLs.
     * @param string|null $excludedReferrers Comma-separated list of hosts/URLs to exclude from referrer detection.
     * @param string|null $description Optional description providing additional context about this site, such as its
     *                                 purpose or usage.
     * @see getKeepURLFragmentsGlobal
     */
    public function updateSite(
        $idSite,
        $siteName = null,
        $urls = null,
        $ecommerce = null,
        $siteSearch = null,
        $searchKeywordParameters = null,
        $searchCategoryParameters = null,
        $excludedIps = null,
        $excludedQueryParameters = null,
        $timezone = null,
        $currency = null,
        $group = null,
        $startDate = null,
        $excludedUserAgents = null,
        $keepURLFragments = null,
        $type = null,
        $settingValues = null,
        $excludeUnknownUrls = null,
        $excludedReferrers = null,
        $description = null
    ): void {
        Piwik::checkUserHasAdminAccess($idSite);
        SitesManager::dieIfSitesAdminIsDisabled();

        $idSites = $this->getSitesId();

        if (!in_array($idSite, $idSites)) {
            throw new Exception("website id = $idSite not found");
        }

        // Build the SQL UPDATE based on specified updates to perform
        $bind = [];

        if (!is_null($siteName)) {
            $this->checkName($siteName);
            $bind['name'] = $siteName;
        }

        if (!is_null($description)) {
            $bind['description'] = $this->checkAndReturnDescription($description);
        }

        if (!isset($settingValues)) {
            $settingValues = [];
        }

        $coreProperties = [];
        $coreProperties = $this->setSettingValue('urls', $urls, $coreProperties, $settingValues);
        $coreProperties = $this->setSettingValue('ecommerce', $ecommerce, $coreProperties, $settingValues);
        $coreProperties = $this->setSettingValue('group', $group, $coreProperties, $settingValues);
        $coreProperties = $this->setSettingValue('sitesearch', $siteSearch, $coreProperties, $settingValues);
        $coreProperties = $this->setSettingValue('sitesearch_keyword_parameters', explode(',', $searchKeywordParameters ?? ''), $coreProperties, $settingValues);
        $coreProperties = $this->setSettingValue('sitesearch_category_parameters', explode(',', $searchCategoryParameters ?? ''), $coreProperties, $settingValues);
        $coreProperties = $this->setSettingValue('keep_url_fragment', $keepURLFragments, $coreProperties, $settingValues);
        $coreProperties = $this->setSettingValue('exclude_unknown_urls', $excludeUnknownUrls, $coreProperties, $settingValues);
        $coreProperties = $this->setSettingValue('excluded_ips', explode(',', $excludedIps ?? ''), $coreProperties, $settingValues);
        $coreProperties = $this->setSettingValue('excluded_parameters', explode(',', $excludedQueryParameters ?? ''), $coreProperties, $settingValues);
        $coreProperties = $this->setSettingValue('excluded_user_agents', explode(',', $excludedUserAgents ?? ''), $coreProperties, $settingValues);
        $coreProperties = $this->setSettingValue('excluded_referrers', explode(',', $excludedReferrers ?? ''), $coreProperties, $settingValues);

        if (isset($currency)) {
            $currency = trim($currency);
            $this->checkValidCurrency($currency);
            $bind['currency'] = $currency;
        }
        if (isset($timezone)) {
            $timezone = trim($timezone);
            $this->checkValidTimezone($timezone);
            $bind['timezone'] = $timezone;
        }
        if (
            isset($group)
            && Piwik::hasUserSuperUserAccess()
        ) {
            $bind['group'] = trim($group);
        }
        if (isset($startDate)) {
            $bind['ts_created'] = Date::factory($startDate)->getDatetime();
        }

        // check and update type only if it has changed
        if (isset($type) && Site::getTypeFor($idSite) !== $type) {
            $bind['type'] = $this->checkAndReturnType($type);
        }

        if (!empty($coreProperties)) {
            $this->setAndValidateMeasurableSettings($idSite, $idType = 'website', $coreProperties);
        }

        if (!empty($settingValues)) {
            $this->setAndValidateMeasurableSettings($idSite, $idType = null, $settingValues);
        }

        if (!empty($bind)) {
            $this->getModel()->updateSite($bind, $idSite);
        }

        if (!empty($coreProperties)) {
            $this->saveMeasurableSettings($idSite, $idType = 'website', $coreProperties);
        }

        if (!empty($settingValues)) {
            $this->saveMeasurableSettings($idSite, $idType = null, $settingValues);
        }

        $this->postUpdateWebsite($idSite);
    }

    /**
     * Updates the field `ts_created` for the specified websites.
     *
     * Sets the creation date to one day before the given `$minDate` to play it safe.
     * Requires admin access to the specified sites.
     *
     * @param int|string|int[]|string[] $idSites Site ID(s) to update (single ID, comma-separated string, or array).
     * @param Date $minDate The minimum date. The creation date will be set to one day before this.
     * @ignore
     */
    public function updateSiteCreatedTime($idSites, Date $minDate): void
    {
        $idSites = Site::getIdSitesFromIdSitesString($idSites, false, true);
        Piwik::checkUserHasAdminAccess($idSites);

        $minDateSql = $minDate->subDay(1)->getDatetime();

        $this->getModel()->updateSiteCreatedTime($idSites, $minDateSql);
    }

    private function checkAndReturnCommaSeparatedStringList(string $parameters): string
    {
        $parameters = trim($parameters);
        if (empty($parameters)) {
            return '';
        }

        $parameters = explode(',', $parameters);
        $parameters = array_map('trim', $parameters);
        $parameters = array_filter($parameters, 'strlen');
        $parameters = array_unique($parameters);
        return implode(',', $parameters);
    }

    /**
     * Returns the list of supported currencies.
     *
     * @return array<string, string> Map of currency code to human-readable name with symbol,
     *                               e.g. `['USD' => 'US Dollar ($)', ...]`.
     * @see getCurrencySymbols()
     */
    public function getCurrencyList(): array
    {
        /** @var CurrencyDataProvider $dataProvider */
        $dataProvider = StaticContainer::get('Piwik\Intl\Data\Provider\CurrencyDataProvider');
        $currency = $dataProvider->getCurrencyList();

        $return = [];
        foreach (array_keys($currency) as $currencyCode) {
            $return[$currencyCode] = $this->translator->translate('Intl_Currency_' . $currencyCode) .
              ' (' . $this->translator->translate('Intl_CurrencySymbol_' . $currencyCode) . ')';
        }

        asort($return);

        return $return;
    }

    /**
     * Returns the list of currency symbols.
     *
     * @return array<string, string> Map of currency code to its symbol, e.g. `['USD' => '$', ...]`.
     * @see getCurrencyList()
     */
    public function getCurrencySymbols(): array
    {
        /** @var CurrencyDataProvider $dataProvider */
        $dataProvider = StaticContainer::get('Piwik\Intl\Data\Provider\CurrencyDataProvider');
        $currencies =  $dataProvider->getCurrencyList();

        return array_map(function ($a) {
            return $a[0];
        }, $currencies);
    }

    /**
     * Returns whether timezone support is enabled on the server.
     *
     * @return bool `true` if timezone support is available.
     */
    public function isTimezoneSupportEnabled(): bool
    {
        Piwik::checkUserHasSomeViewAccess();
        return SettingsServer::isTimezoneSupportEnabled();
    }

    /**
     * Returns the list of supported timezones, grouped by continent.
     *
     * Used for {@see addSite()} and {@see updateSite()} to populate timezone selectors.
     *
     * @return array<string, array<string, string>> Timezones grouped by continent name.
     *                                              Each entry maps a timezone identifier to its human-readable label.
     */
    public function getTimezonesList(): array
    {
        if (!SettingsServer::isTimezoneSupportEnabled()) {
            return ['UTC' => $this->getTimezonesListUTCOffsets()];
        }

        $countries = StaticContainer::get('Piwik\Intl\Data\Provider\RegionDataProvider')->getCountryList();

        $return = [];
        $continents = [];
        foreach ($countries as $countryCode => $continentCode) {
            $countryCode = strtoupper($countryCode);
            $timezones = DateTimeZone::listIdentifiers(DateTimeZone::PER_COUNTRY, $countryCode);
            foreach ($timezones as $timezone) {
                if (!isset($continents[$continentCode])) {
                    $continents[$continentCode] = $this->translator->translate('Intl_Continent_' . $continentCode);
                }
                $continent = $continents[$continentCode];

                $return[$continent][$timezone] = $this->getTimezoneName($timezone, $countryCode, count($timezones) > 1);
            }
        }

        // Sort by continent name and then by country name.
        ksort($return);
        foreach ($return as $continent => $countries) {
            asort($return[$continent]);
        }

        $return['UTC'] = $this->getTimezonesListUTCOffsets();
        return $return;
    }

    /**
     * Returns a user-friendly label for a timezone.
     * This is usually the country name of the timezone. For countries spanning multiple timezones,
     * a city/location name is added to avoid ambiguity.
     *
     * @param string $timezone A timezone, e.g. `'Asia/Tokyo'` or `'America/Los_Angeles'`.
     * @param string|null $countryCode An upper-case country code, or `null` to look it up automatically.
     * @param bool|null $multipleTimezonesInCountry Whether there are multiple timezones in the country,
     *                                              or `null` to look it up automatically.
     * @return string a timezone label, e.g. "Japan" or "United States - Los Angeles"
     */
    public function getTimezoneName(string $timezone, ?string $countryCode = null, ?bool $multipleTimezonesInCountry = null): string
    {
        if (substr($timezone, 0, 3) === 'UTC') {
            return $this->translator->translate('SitesManager_Format_Utc', str_replace(['.25', '.5', '.75'], [':15', ':30', ':45'], substr($timezone, 3)));
        }

        if (!isset($countryCode)) {
            try {
                $zone = new DateTimeZone($timezone);
                $location = $zone->getLocation();
                if (isset($location['country_code']) && $location['country_code'] !== '??') {
                    $countryCode = $location['country_code'];
                }
            } catch (Exception $e) {
            }
        }

        if (!$countryCode) {
            $timezoneExploded = explode('/', $timezone);
            return str_replace('_', ' ', end($timezoneExploded));
        }

        if (!isset($multipleTimezonesInCountry)) {
            $timezonesInCountry = DateTimeZone::listIdentifiers(DateTimeZone::PER_COUNTRY, $countryCode);
            $multipleTimezonesInCountry = (count($timezonesInCountry) > 1);
        }

        $return = $this->translator->translate('Intl_Country_' . $countryCode);

        if ($multipleTimezonesInCountry) {
            $translationId = 'Intl_Timezone_' . str_replace(['_', '/'], ['', '_'], $timezone);
            $city = $this->translator->translate($translationId);

            // Fall back to English identifier, if translation is missing due to differences in tzdata in different PHP versions.
            if ($city === $translationId) {
                $timezoneExploded = explode('/', $timezone);
                $city = str_replace('_', ' ', end($timezoneExploded));
            }

            $return .= ' - ' . $city;
        }

        return $return;
    }

    /**
     * @return array<string, string>
     */
    private function getTimezonesListUTCOffsets(): array
    {
        // manually add the UTC offsets
        $GmtOffsets = [-12, -11.5, -11, -10.5, -10, -9.5, -9, -8.5, -8, -7.5, -7, -6.5, -6, -5.5, -5, -4.5, -4, -3.5, -3, -2.5, -2, -1.5, -1, -0.5,
                            0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 5.75, 6, 6.5, 7, 7.5, 8, 8.5, 8.75, 9, 9.5, 10, 10.5, 11, 11.5, 12, 12.75, 13, 13.75, 14];

        $return = [];
        foreach ($GmtOffsets as $offset) {
            $offset = Common::forceDotAsSeparatorForDecimalPoint($offset);

            if ($offset > 0) {
                $offset = '+' . $offset;
            } elseif ($offset == 0) {
                $offset = '';
            }
            $timezone = 'UTC' . $offset;
            $return[$timezone] = $this->getTimezoneName($timezone);
        }
        return $return;
    }

    /**
     * Returns the list of unique timezones from all configured sites.
     *
     * Requires Superuser access.
     *
     * @return list<string> Distinct timezone identifiers currently in use.
     */
    public function getUniqueSiteTimezones(): array
    {
        Piwik::checkUserHasSuperUserAccess();

        return $this->getModel()->getUniqueSiteTimezones();
    }

    /**
     * Remove the final slash in the URLs if found
     *
     * @return string the URL without the trailing slash
     */
    private function removeTrailingSlash(string $url): string
    {
        // if there is a final slash, we take the URL without this slash (expected URL format)
        if (
            strlen($url) > 5
            && $url[strlen($url) - 1] == '/'
        ) {
            $url = substr($url, 0, strlen($url) - 1);
        }

        return $url;
    }

    /**
     * Tests if the IP is a valid IP, allowing wildcards, except in the first octet.
     * Wildcards can only be used from right to left, ie. 1.1.*.* is allowed, but 1.1.*.1 is not.
     *
     * @param string $ip IP address
     */
    private function isValidIp(string $ip): bool
    {
        return IPUtils::getIPRangeBounds($ip) !== null;
    }

    /**
     * Check that the website name has a correct format.
     *
     * @param string $siteName
     */
    private function checkName($siteName): void
    {
        if (empty($siteName)) {
            throw new Exception($this->translator->translate("SitesManager_ExceptionEmptyName"));
        }
    }

    /**
     * Normalises the site description and ensures it does not exceed the allowed length.
     */
    private function checkAndReturnDescription(?string $description): string
    {
        $description = trim((string) $description);

        if (mb_strlen($description) > 255) {
            throw new Exception(Piwik::translate('SitesManager_ExceptionInvalidWebsiteDescription'));
        }

        return $description;
    }

    /**
     * Renames a website group across all sites that currently use it.
     *
     * Requires Superuser access.
     *
     * @param string $oldGroupName Existing group name to replace.
     * @param string $newGroupName New group name to assign.
     * @return bool Always `true`.
     */
    public function renameGroup(string $oldGroupName, string $newGroupName): bool
    {
        Piwik::checkUserHasSuperUserAccess();

        if ($oldGroupName == $newGroupName) {
            return true;
        }

        $sitesHavingOldGroup = $this->getSitesFromGroup($oldGroupName);

        foreach ($sitesHavingOldGroup as $site) {
            $this->updateSite(
                $site['idsite'],
                $siteName = null,
                $urls = null,
                $ecommerce = null,
                $siteSearch = null,
                $searchKeywordParameters = null,
                $searchCategoryParameters = null,
                $excludedIps = null,
                $excludedQueryParameters = null,
                $timezone = null,
                $currency = null,
                $newGroupName
            );
        }

        return true;
    }

    /**
     * Find websites matching the given pattern.
     *
     * Any website will be returned that matches the pattern in its name, URL or group.
     * To limit the number of returned sites you can either specify `filter_limit` as usual or `limit` which is
     * faster. Only sites the current user has at least view access to are returned.
     *
     * @param string $pattern Search string to match against site name, URL or group.
     * @param int|false $limit Maximum number of sites to return, or `false` for no limit.
     * @param int[] $sitesToExclude Site IDs to exclude from the result.
     * @return list<SiteData> Matching websites.
     */
    public function getPatternMatchSites(string $pattern, $limit = false, $sitesToExclude = []): array
    {
        $ids = $this->getSitesIdWithAtLeastViewAccess();

        // Remove the sites to exclude from the list of IDs.
        if (count($sitesToExclude)) {
            $ids = array_diff($ids, $sitesToExclude);
        }

        if (empty($ids)) {
            return [];
        }

        $sites = $this->getModel()->getPatternMatchSites($ids, $pattern, $limit ?: null);

        foreach ($sites as &$site) {
            $this->enrichSite($site);
        }

        $sites = Site::setSitesFromArray($sites);

        return $sites;
    }

    /**
     * Returns the number of websites to display per page.
     *
     * For example this is used in the All Websites Dashboard and the Website Selector.
     * If multiple websites are shown somewhere, this method should be used to determine how
     * many websites should be shown per page when using pagination. Pagination is recommended
     * since some installations have thousands of websites.
     *
     * @return int Number of websites per page.
     */
    public function getNumWebsitesToDisplayPerPage(): int
    {
        Piwik::checkUserHasSomeViewAccess();

        return SettingsPiwik::getWebsitesCountToDisplay();
    }


    /**
     * Detect consent manager details for a site.
     *
     * Scans the site's content to identify which consent manager (if any) is in use
     * and whether it is properly connected to Matomo.
     *
     * @param int $idSite  The numeric ID of the website to inspect.
     * @param int $timeOut HTTP timeout in seconds for fetching the site content.
     * @return array{name: string, url: string|null, isConnected: bool}|null Detected consent manager details,
     *                                                                      or `null` if none was found.
     * @internal
     * @unsanitized
     */
    public function detectConsentManager(int $idSite, int $timeOut = 60): ?array
    {
        Piwik::checkUserHasViewAccess($idSite);

        $timeOut = max(1, min($timeOut, 60));

        $this->siteContentDetector->detectContent([SiteContentDetectionAbstract::TYPE_CONSENT_MANAGER], $idSite, null, $timeOut);
        $consentManagers = $this->siteContentDetector->getDetectsByType(SiteContentDetectionAbstract::TYPE_CONSENT_MANAGER);
        if (!empty($consentManagers)) {
            /** @var ConsentManagerDetectionAbstract $consentManager */
            $consentManager = $this->siteContentDetector->getSiteContentDetectionById(reset($consentManagers));
            return ['name' => $consentManager::getName(),
                    'url' => $consentManager::getInstructionUrl(),
                    'isConnected' => in_array($consentManager::getId(), $this->siteContentDetector->connectedConsentManagers),
            ];
        }

        return null;
    }
}
