<?php

// SPDX-FileCopyrightText: 2018 Icinga GmbH <https://icinga.com>
// SPDX-License-Identifier: GPL-3.0-or-later

namespace Icinga\Web\Navigation;

use ArrayAccess;
use ArrayIterator;
use Exception;
use Countable;
use InvalidArgumentException;
use IteratorAggregate;
use Traversable;
use Icinga\Application\Icinga;
use Icinga\Application\Logger;
use Icinga\Authentication\Auth;
use Icinga\Data\ConfigObject;
use Icinga\Exception\ConfigurationError;
use Icinga\Exception\IcingaException;
use Icinga\Exception\ProgrammingError;
use Icinga\Util\StringHelper;
use Icinga\Web\Navigation\Renderer\RecursiveNavigationRenderer;

/**
 * Container for navigation items
 */
class Navigation implements ArrayAccess, Countable, IteratorAggregate
{
    /**
     * The class namespace where to locate navigation type classes
     *
     * @var string
     */
    const NAVIGATION_NS = 'Web\\Navigation';

    /**
     * Flag for dropdown layout
     *
     * @var int
     */
    const LAYOUT_DROPDOWN = 1;

    /**
     * Flag for tabs layout
     *
     * @var int
     */
    const LAYOUT_TABS = 2;

    /**
     * Known navigation types
     *
     * @var array
     */
    protected static $types;

    /**
     * This navigation's items
     *
     * @var NavigationItem[]
     */
    protected $items = array();

    /**
     * This navigation's layout
     *
     * @var int
     */
    protected $layout;

    public function offsetExists($offset): bool
    {
        return isset($this->items[$offset]);
    }

    public function offsetGet($offset): ?NavigationItem
    {
        return $this->items[$offset] ?? null;
    }

    public function offsetSet($offset, $value): void
    {
        $this->items[$offset] = $value;
    }

    public function offsetUnset($offset): void
    {
        unset($this->items[$offset]);
    }

    public function count(): int
    {
        return count($this->items);
    }

    public function getIterator(): Traversable
    {
        $this->order();
        return new ArrayIterator($this->items);
    }

    /**
     * Create and return a new navigation item for the given configuration
     *
     * @param   string              $name
     * @param   array|ConfigObject  $properties
     *
     * @return  NavigationItem
     *
     * @throws  InvalidArgumentException    If the $properties argument is neither an array nor a ConfigObject
     */
    public function createItem($name, $properties)
    {
        if ($properties instanceof ConfigObject) {
            $properties = $properties->toArray();
        } elseif (! is_array($properties)) {
            throw new InvalidArgumentException('Argument $properties must be of type array or ConfigObject');
        }

        $itemType = isset($properties['type']) ? StringHelper::cname($properties['type'], '-') : 'NavigationItem';
        if (! empty(static::$types) && isset(static::$types[$itemType])) {
            return new static::$types[$itemType]($name, $properties);
        }

        $item = null;
        $classPath = null;
        foreach (Icinga::app()->getModuleManager()->getLoadedModules() as $module) {
            $classPath = 'Icinga\\Module\\'
                . ucfirst($module->getName())
                . '\\'
                . static::NAVIGATION_NS
                . '\\'
                . $itemType;
            if (class_exists($classPath)) {
                $item = new $classPath($name, $properties);
                break;
            }
        }

        if ($item === null) {
            $classPath = 'Icinga\\' . static::NAVIGATION_NS . '\\' . $itemType;
            if (class_exists($classPath)) {
                $item = new $classPath($name, $properties);
            }
        }

        if ($item === null) {
            if ($itemType !== 'MenuItem') {
                Logger::debug(
                    'Failed to find custom navigation item class %s for item %s. Using base class NavigationItem now',
                    $itemType,
                    $name
                );
            }

            $item = new NavigationItem($name, $properties);
            static::$types[$itemType] = 'Icinga\\Web\\Navigation\\NavigationItem';
        } elseif (! $item instanceof NavigationItem) {
            throw new ProgrammingError('Class %s must inherit from NavigationItem', $classPath);
        } else {
            static::$types[$itemType] = $classPath;
        }

        return $item;
    }

    /**
     * Add a navigation item
     *
     * If you do not pass an instance of NavigationItem, this will only add the item
     * if it does not require a permission or the current user has the permission.
     *
     * @param   string|NavigationItem   $name       The name of the item or an instance of NavigationItem
     * @param   array                   $properties The properties of the item to add (Ignored if $name is not a string)
     *
     * @return  bool                                Whether the item was added or not
     *
     * @throws  InvalidArgumentException            In case $name is neither a string nor an instance of NavigationItem
     */
    public function addItem($name, array $properties = array())
    {
        if (is_string($name)) {
            if (isset($properties['permission'])) {
                if (! Auth::getInstance()->hasPermission($properties['permission'])) {
                    return false;
                }

                unset($properties['permission']);
            }

            $item = $this->createItem($name, $properties);
        } elseif (! $name instanceof NavigationItem) {
            throw new InvalidArgumentException('Argument $name must be of type string or NavigationItem');
        } else {
            $item = $name;
        }

        $this->items[$item->getName()] = $item;
        return true;
    }

    /**
     * Return the item with the given name
     *
     * @param   string  $name
     * @param   mixed   $default
     *
     * @return  NavigationItem|mixed
     */
    public function getItem($name, $default = null)
    {
        return isset($this->items[$name]) ? $this->items[$name] : $default;
    }

    /**
     * Return the currently active item or the first one if none is active
     *
     * @return  NavigationItem
     */
    public function getActiveItem()
    {
        foreach ($this->items as $item) {
            if ($item->getActive()) {
                return $item;
            }
        }

        $firstItem = reset($this->items);
        return $firstItem ? $firstItem->setActive() : null;
    }

    /**
     * Return this navigation's items
     *
     * @return  array
     */
    public function getItems()
    {
        return $this->items;
    }

    /**
     * Return whether this navigation is empty
     *
     * @return  bool
     */
    public function isEmpty()
    {
        return empty($this->items);
    }

    /**
     * Return whether this navigation has any renderable items
     *
     * @return  bool
     */
    public function hasRenderableItems()
    {
        foreach ($this->getItems() as $item) {
            if ($item->shouldRender()) {
                return true;
            }
        }

        return false;
    }

    /**
     * Return this navigation's layout
     *
     * @return  int
     */
    public function getLayout()
    {
        return $this->layout;
    }

    /**
     * Set this navigation's layout
     *
     * @param   int     $layout
     *
     * @return  $this
     */
    public function setLayout($layout)
    {
        $this->layout = (int) $layout;
        return $this;
    }

    /**
     * Create and return the renderer for this navigation
     *
     * @return  RecursiveNavigationRenderer
     */
    public function getRenderer()
    {
        return new RecursiveNavigationRenderer($this);
    }

    /**
     * Return this navigation rendered to HTML
     *
     * @return  string
     */
    public function render()
    {
        return $this->getRenderer()->render();
    }

    /**
     * Order this navigation's items
     *
     * @return  $this
     */
    public function order()
    {
        uasort($this->items, array($this, 'compareItems'));
        foreach ($this->items as $item) {
            if ($item->hasChildren()) {
                $item->getChildren()->order();
            }
        }

        return $this;
    }

    /**
     * Return whether the first item is less than, more than or equal to the second one
     *
     * @param   NavigationItem  $a
     * @param   NavigationItem  $b
     *
     * @return  int
     */
    protected function compareItems(NavigationItem $a, NavigationItem $b)
    {
        if ($a->getPriority() === $b->getPriority()) {
            return strcasecmp($a->getLabel(), $b->getLabel());
        }

        return $a->getPriority() > $b->getPriority() ? 1 : -1;
    }

    /**
     * Try to find and return a item with the given or a similar name
     *
     * @param   string  $name
     *
     * @return  ?NavigationItem
     */
    public function findItem($name)
    {
        $item = $this->getItem($name);
        if ($item !== null) {
            return $item;
        }

        $loweredName = strtolower($name);
        foreach ($this->getItems() as $item) {
            if (strtolower($item->getName()) === $loweredName) {
                return $item;
            }
        }
    }

    /**
     * Merge this navigation with the given one
     *
     * Any duplicate items of this navigation will be overwritten by the given navigation's items.
     *
     * @param   Navigation  $navigation
     *
     * @return  $this
     */
    public function merge(Navigation $navigation)
    {
        foreach ($navigation as $item) {
            /** @var $item NavigationItem */
            if (($existingItem = $this->findItem($item->getName())) !== null) {
                if ($existingItem->conflictsWith($item)) {
                    $name = $item->getName();
                    do {
                        if (preg_match('~_(\d+)$~', $name, $matches)) {
                            $name = preg_replace('~_\d+$~', (int) $matches[1] + 1, $name);
                        } else {
                            $name .= '_2';
                        }
                    } while ($this->getItem($name) !== null);

                    $this->addItem($item->setName($name));
                } else {
                    $existingItem->merge($item);
                }
            } else {
                $this->addItem($item);
            }
        }

        return $this;
    }

    /**
     * Extend this navigation set with all additional items of the given type
     *
     * This will fetch navigation items from the following sources:
     * * User Shareables
     * * User Preferences
     * * Modules
     * Any existing entry will be overwritten by one that is coming later in order.
     *
     * @param   string  $type
     *
     * @return  $this
     */
    public function load($type)
    {
        $user = Auth::getInstance()->getUser();
        if ($type !== 'dashboard-pane') {
            // Shareables
            $this->merge(Icinga::app()->getSharedNavigation($type));

            // User Preferences
            $this->merge($user->getNavigation($type));
        }

        // Modules
        $moduleManager = Icinga::app()->getModuleManager();
        foreach ($moduleManager->getLoadedModules() as $module) {
            if ($user->can($moduleManager::MODULE_PERMISSION_NS . $module->getName())) {
                if ($type === 'menu-item') {
                    $this->merge($module->getMenu());
                } elseif ($type === 'dashboard-pane') {
                    $this->merge($module->getDashboard());
                }
            }
        }

        return $this;
    }

    /**
     * Return the global navigation item type configuration
     *
     * @return  array
     */
    public static function getItemTypeConfiguration()
    {
        $defaultItemTypes = array(
            'menu-item' => array(
                'label'     => t('Menu Entry'),
                'config'    => 'menu'
            )/*, // Disabled, until it is able to fully replace the old implementation
            'dashlet'   => array(
                'label'     => 'Dashlet',
                'config'    => 'dashboard'
            )*/
        );

        $moduleItemTypes = array();
        $moduleManager = Icinga::app()->getModuleManager();
        foreach ($moduleManager->getLoadedModules() as $module) {
            if (Auth::getInstance()->hasPermission($moduleManager::MODULE_PERMISSION_NS . $module->getName())) {
                foreach ($module->getNavigationItems() as $type => $options) {
                    if (! isset($moduleItemTypes[$type])) {
                        $moduleItemTypes[$type] = $options;
                    }
                }
            }
        }

        return array_merge($defaultItemTypes, $moduleItemTypes);
    }

    /**
     * Create and return a new set of navigation items for the given configuration
     *
     * Note that this is supposed to be utilized for one dimensional structures
     * only. Multi dimensional structures can be processed by fromArray().
     *
     * @param   Traversable|array   $config
     *
     * @return  Navigation
     *
     * @throws  InvalidArgumentException    In case the given configuration is invalid
     * @throws  ConfigurationError          In case a referenced parent does not exist
     */
    public static function fromConfig($config)
    {
        if (! is_array($config) && !$config instanceof Traversable) {
            throw new InvalidArgumentException('Argument $config must be an array or a instance of Traversable');
        }

        $flattened = $orphans = $topLevel = array();
        foreach ($config as $sectionName => $sectionConfig) {
            $parentName = $sectionConfig->parent;
            unset($sectionConfig->parent);

            if (! $parentName) {
                $topLevel[$sectionName] = $sectionConfig->toArray();
                $flattened[$sectionName] = & $topLevel[$sectionName];
            } elseif (isset($flattened[$parentName])) {
                $flattened[$parentName]['children'][$sectionName] = $sectionConfig->toArray();
                $flattened[$sectionName] = & $flattened[$parentName]['children'][$sectionName];
            } else {
                $orphans[$parentName][$sectionName] = $sectionConfig->toArray();
                $flattened[$sectionName] = & $orphans[$parentName][$sectionName];
            }
        }

        do {
            $match = false;
            foreach ($orphans as $parentName => $children) {
                if (isset($flattened[$parentName])) {
                    if (isset($flattened[$parentName]['children'])) {
                        $flattened[$parentName]['children'] = array_merge(
                            $flattened[$parentName]['children'],
                            $children
                        );
                    } else {
                        $flattened[$parentName]['children'] = $children;
                    }

                    unset($orphans[$parentName]);
                    $match = true;
                }
            }
        } while ($match && !empty($orphans));

        if (! empty($orphans)) {
            throw new ConfigurationError(
                t(
                    'Failed to fully parse navigation configuration. Ensure that'
                    . ' all referenced parents are existing navigation items: %s'
                ),
                join(', ', array_keys($orphans))
            );
        }

        return static::fromArray($topLevel);
    }

    /**
     * Create and return a new set of navigation items for the given array
     *
     * @param   array   $array
     *
     * @return  Navigation
     */
    public static function fromArray(array $array)
    {
        $navigation = new static();
        foreach ($array as $name => $properties) {
            $navigation->addItem((string) $name, $properties);
        }

        return $navigation;
    }

    /**
     * Return this navigation rendered to HTML
     *
     * @return  string
     */
    public function __toString()
    {
        try {
            return $this->render();
        } catch (Exception $e) {
            return IcingaException::describe($e);
        }
    }
}
