<?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\Menu;

use Piwik\Cache;
use Piwik\Container\StaticContainer;
use Piwik\Plugins\SitesManager\API;
use Piwik\Singleton;
use Piwik\Plugin\Manager as PluginManager;

/**
 * Base class for classes that manage one of Piwik's menus.
 *
 * There are three menus in Piwik, the main menu, the top menu and the admin menu.
 * Each menu has a class that manages the menu's content. Each class invokes
 * a different event to allow plugins to add new menu items.
 *
 * @static \Piwik\Menu\MenuAbstract getInstance()
 */
abstract class MenuAbstract extends Singleton
{
    /**
     * @var array
     */
    protected $menu = null;
    /**
     * @var array
     */
    protected $menuEntries = [];
    /**
     * @var array
     */
    protected $menuEntriesToRemove = [];
    /**
     * @var array
     */
    protected $edits = [];
    /**
     * @var array
     */
    protected $renames = [];
    /**
     * @var bool
     */
    protected $orderingApplied = false;
    /**
     * @var array<string, string>
     */
    protected $menuIcons = [];

    /**
     * Builds the menu, applies edits, renames
     * and orders the entries.
     *
     * @return array
     */
    public function getMenu()
    {
        $this->buildMenu();
        $this->applyEdits();
        $this->applyRemoves();
        $this->applyRenames();
        $this->applyOrdering();
        return $this->menu;
    }

    /**
     * lets you register a menu icon for a certain menu category to replace the default arrow icon.
     *
     * @param string $menuName The translation key of a main menu category, eg 'Dashboard_Dashboard'
     * @param string $iconCssClass   The css class name of an icon, eg 'icon-user'
     * @return void
     */
    public function registerMenuIcon($menuName, $iconCssClass)
    {
        $this->menuIcons[$menuName] = $iconCssClass;
    }

    /**
     * Returns a list of available plugin menu instances.
     *
     * @return \Piwik\Plugin\Menu[]
     */
    protected function getAllMenus()
    {
        $cacheId = 'Menus.all';
        $cache = Cache::getTransientCache();

        if ($cache->contains($cacheId)) {
            return $cache->fetch($cacheId);
        }

        $components = PluginManager::getInstance()->findComponents('Menu', 'Piwik\\Plugin\\Menu');

        $menus = [];
        foreach ($components as $component) {
            $menus[] = StaticContainer::get($component);
        }

        $cache->save($cacheId, $menus);

        return $menus;
    }

    /**
     * Adds a new entry to the menu.
     *
     * @param string $menuName The menu's category name. Can be a translation token.
     * @param null|string $subMenuName The menu item's name. Can be a translation token.
     * @param string|array<string, scalar> $url The URL the admin menu entry should link to, or an array of query parameters
     *                          that can be used to build the URL.
     * @param int $order The order hint.
     * @param string|null|false $tooltip An optional tooltip to display or false to display the tooltip.
     * @param string|null|false $icon An icon classname, such as "icon-add". Only supported by admin menu
     * @param string|null|false $onclick Will execute the on click handler instead of executing the link. Only supported by admin menu.
     * @param string|null|false $attribute Will add this string as a link attribute.
     * @param string|null|false $help Will display a help icon that will pop a notification with help information.
     * @param int $badgeCount If non-zero then a badge will be overlaid on the icon showing the provided count
     * @param string $cssClass If a string is provided, it will be added as an extra CSS class to the menu item
     * @return void
     * @since 2.7.0
     * @api
     */
    public function addItem(
        string $menuName,
        ?string $subMenuName,
        $url,
        int $order = 50,
        $tooltip = false,
        $icon = false,
        $onclick = false,
        $attribute = false,
        $help = false,
        int $badgeCount = 0,
        string $cssClass = ''
    ) {
        // make sure the idSite value used is numeric (hack-y fix for #3426)
        if (isset($url['idSite']) && !is_numeric($url['idSite'])) {
            $idSites = API::getInstance()->getSitesIdWithAtLeastViewAccess();
            $url['idSite'] = reset($idSites);
        }

        $this->menuEntries[] = [
            $menuName,
            $subMenuName,
            $url,
            $order,
            $tooltip,
            $icon,
            $onclick,
            $attribute,
            $help,
            $badgeCount,
            $cssClass,
        ];
    }

    /**
     * Removes an existing entry from the menu.
     *
     * @param string      $menuName    The menu's category name. Can be a translation token.
     * @param string|null|false $subMenuName The menu item's name. Can be a translation token.
     * @return void
     * @api
     */
    public function remove($menuName, $subMenuName = false)
    {
        $this->menuEntriesToRemove[] = array(
            $menuName,
            $subMenuName,
        );
    }

    /**
     * Builds a single menu item
     *
     * @param string|array $url
     * @param string|null|false $tooltip Tooltip to display.
     * @param string|null|false $icon
     * @param string|null|false $onclick
     * @param string|null|false $attribute
     * @param string|null|false $help
     */
    private function buildMenuItem(
        string $menuName,
        ?string $subMenuName,
        $url,
        int $order = 50,
        $tooltip = false,
        $icon = false,
        $onclick = false,
        $attribute = false,
        $help = false,
        int $badgeCount = 0,
        string $cssClass = ''
    ): void {
        if (!isset($this->menu[$menuName])) {
            $this->menu[$menuName] = [
                '_hasSubmenu' => false,
                '_order' => $order,
            ];
        }

        if (empty($subMenuName)) {
            $this->menu[$menuName]['_url']   = $url;
            $this->menu[$menuName]['_order'] = $order;
            $this->menu[$menuName]['_name']  = $menuName;
            $this->menu[$menuName]['_tooltip'] = $tooltip;
            $this->menu[$menuName]['_attribute'] = $attribute;
            if (!empty($this->menuIcons[$menuName])) {
                $this->menu[$menuName]['_icon'] = $this->menuIcons[$menuName];
            } else {
                $this->menu[$menuName]['_icon'] = '';
            }
            if (!empty($onclick)) {
                $this->menu[$menuName]['_onclick'] = $onclick;
            }
            $this->menu[$menuName]['_help'] = $help ?: '';
            $this->menu[$menuName]['_badgecount'] = $badgeCount;
            $this->menu[$menuName]['_cssClass'] = $cssClass;
        }
        if (!empty($subMenuName)) {
            $this->menu[$menuName][$subMenuName]['_url'] = $url;
            $this->menu[$menuName][$subMenuName]['_order'] = $order;
            $this->menu[$menuName][$subMenuName]['_name'] = $subMenuName;
            $this->menu[$menuName][$subMenuName]['_tooltip'] = $tooltip;
            $this->menu[$menuName][$subMenuName]['_attribute'] = $attribute;
            $this->menu[$menuName][$subMenuName]['_icon'] = $icon;
            $this->menu[$menuName][$subMenuName]['_onclick'] = $onclick;
            $this->menu[$menuName][$subMenuName]['_help'] = $help ?: '';
            $this->menu[$menuName][$subMenuName]['_badgecount'] = $badgeCount;
            $this->menu[$menuName][$subMenuName]['_cssClass'] = $cssClass;
            $this->menu[$menuName]['_hasSubmenu'] = true;

            if (!array_key_exists('_tooltip', $this->menu[$menuName])) {
                $this->menu[$menuName]['_tooltip'] = $tooltip;
            }
        }
    }

    /**
     * Builds the menu from the $this->menuEntries variable.
     */
    private function buildMenu(): void
    {
        foreach ($this->menuEntries as $menuEntry) {
            $this->buildMenuItem(...$menuEntry);
        }
    }

    /**
     * Renames a single menu entry.
     *
     * @param string $mainMenuOriginal
     * @param string|null $subMenuOriginal
     * @param string $mainMenuRenamed
     * @param string|null $subMenuRenamed
     * @phpstan-param ($subMenuOriginal is null ? null : string) $subMenuRenamed
     * @return void
     * @api
     */
    public function rename($mainMenuOriginal, $subMenuOriginal, $mainMenuRenamed, $subMenuRenamed)
    {
        $this->renames[] = [
            $mainMenuOriginal,
            $subMenuOriginal,
            $mainMenuRenamed,
            $subMenuRenamed,
        ];
    }

    /**
     * Edits a URL of an existing menu entry.
     *
     * @param string $mainMenuToEdit
     * @param string|null $subMenuToEdit
     * @param string|array<string, scalar> $newUrl
     * @return void
     * @api
     */
    public function editUrl($mainMenuToEdit, $subMenuToEdit, $newUrl)
    {
        $this->edits[] = [$mainMenuToEdit, $subMenuToEdit, $newUrl];
    }

    /**
     * Applies all edits to the menu.
     */
    private function applyEdits(): void
    {
        foreach ($this->edits as $edit) {
            $mainMenuToEdit = $edit[0];
            $subMenuToEdit  = $edit[1];
            $newUrl         = $edit[2];

            if ($subMenuToEdit === null) {
                if (isset($this->menu[$mainMenuToEdit])) {
                    $menuDataToEdit = &$this->menu[$mainMenuToEdit];
                } else {
                    $menuDataToEdit = null;
                }
            } else {
                if (isset($this->menu[$mainMenuToEdit][$subMenuToEdit])) {
                    $menuDataToEdit = &$this->menu[$mainMenuToEdit][$subMenuToEdit];
                } else {
                    $menuDataToEdit = null;
                }
            }

            if (empty($menuDataToEdit)) {
                $this->buildMenuItem($mainMenuToEdit, $subMenuToEdit, $newUrl);
            } else {
                $menuDataToEdit['_url'] = $newUrl;
            }
        }
    }

    private function applyRemoves(): void
    {
        foreach ($this->menuEntriesToRemove as $menuToDelete) {
            if (empty($menuToDelete[1])) {
                // Delete Main Menu
                if (isset($this->menu[$menuToDelete[0]])) {
                    unset($this->menu[$menuToDelete[0]]);
                }
            } else {
                // Delete Sub Menu
                if (isset($this->menu[$menuToDelete[0]][$menuToDelete[1]])) {
                    unset($this->menu[$menuToDelete[0]][$menuToDelete[1]]);
                }
            }
        }
    }
    /**
     * Applies renames to the menu.
     */
    private function applyRenames(): void
    {
        foreach ($this->renames as $rename) {
            $mainMenuOriginal = $rename[0];
            $subMenuOriginal  = $rename[1];
            $mainMenuRenamed  = $rename[2];
            $subMenuRenamed   = $rename[3];

            // Are we changing a submenu?
            if (!empty($subMenuOriginal)) {
                if (isset($this->menu[$mainMenuOriginal][$subMenuOriginal])) {
                    $save = $this->menu[$mainMenuOriginal][$subMenuOriginal];
                    $save['_name'] = $subMenuRenamed;
                    unset($this->menu[$mainMenuOriginal][$subMenuOriginal]);
                    $this->menu[$mainMenuRenamed][$subMenuRenamed] = $save;
                }
            } elseif (isset($this->menu[$mainMenuOriginal])) {
                // Changing a first-level element
                $save = $this->menu[$mainMenuOriginal];
                $save['_name'] = $mainMenuRenamed;
                unset($this->menu[$mainMenuOriginal]);
                $this->menu[$mainMenuRenamed] = $save;
            }
        }
    }

    /**
     * Orders the menu according to their order.
     */
    private function applyOrdering(): void
    {
        if (
            empty($this->menu)
            || $this->orderingApplied
        ) {
            return;
        }

        uasort($this->menu, [$this, 'menuCompare']);
        foreach ($this->menu as $key => &$element) {
            if (is_null($element)) {
                unset($this->menu[$key]);
            } elseif ($element['_hasSubmenu']) {
                uasort($element, [$this, 'menuCompare']);
            }
        }

        $this->orderingApplied = true;
    }

    /**
     * Compares two menu entries. Used for ordering.
     *
     * @param array|string $itemOne
     * @param array|string $itemTwo
     * @return int
     */
    protected function menuCompare($itemOne, $itemTwo)
    {
        if (!is_array($itemOne) && !is_array($itemTwo)) {
            return 0;
        }

        if (!is_array($itemOne) && is_array($itemTwo)) {
            return -1;
        }

        if (is_array($itemOne) && !is_array($itemTwo)) {
            return 1;
        }

        if (!isset($itemOne['_order']) && !isset($itemTwo['_order'])) {
            return 0;
        }

        if (!isset($itemOne['_order']) && isset($itemTwo['_order'])) {
            return -1;
        }

        if (isset($itemOne['_order']) && !isset($itemTwo['_order'])) {
            return 1;
        }

        if ($itemOne['_order'] == $itemTwo['_order']) {
            return strcmp(
                $itemOne['_name'] ?? '',
                $itemTwo['_name'] ?? ''
            );
        }

        return ($itemOne['_order'] < $itemTwo['_order']) ? -1 : 1;
    }
}
