<?php

namespace Velis\Model;

use LogicException;
use Psr\SimpleCache\InvalidArgumentException;
use ReflectionClass;
use ReflectionException;
use Velis\App;
use Velis\Exception as VelisException;
use Velis\Lang;
use Velis\Model\Exceptions\CircularReferenceException;
use Velis\Output;
use Velis\ParameterBag;

/**
 * Hierarchical structures model (ie. category tree)
 *
 * <b>For Cacheable class redeclare $_source var</b>
 *
 * @author Olek Procki <olo@velis.pl>
 * @author Robert Jamróz <robert.jamroz@velis.pl>
 */
abstract class Hierarchical extends DataObject
{
    /**
     * Buffer for not cached model type (must be redeclared in Cacheable class)
     * @var Hierarchical[]
     */
    protected static $_source;


    /**
     * Returns element parent id
     * @return int
     */
    abstract public function getParentId();

    /**
     * Loads source data for tree
     * @param ParameterBag|array|null $filter
     * @throws VelisException
     * @throws ReflectionException
     */
    protected static function _loadSource($filter = null)
    {
        if (!isset(static::$_source)) {
            $instance = new static();

            if ($instance instanceof Cacheable) {
                $classInfo = new ReflectionClass(get_class($instance));
                $prop = $classInfo->getProperty('_source');

                if ($prop->class == 'Velis\Model\Hierarchical') {
                    VelisException::raise(
                        'Class \'' . get_class($instance) . '\' implements Cacheable must have field \'protected static $_source\' redeclared. '
                    );
                }
                static::$_source = static::listCached();
            } else {
                static::$_source = static::listAll($filter);
            }
        }
    }


    /**
     * {@inheritDoc}
     */
    public function modify($checkDiff = false)
    {
        if ($this->getParentId()) {
            if ($this->getParentId() == $this->id()) {
                throw new CircularReferenceException();
            }

            if ($this instanceof Cacheable) {
                if (in_array($this->getParentId(), $this->getChildrenIds(true))) {
                    throw new CircularReferenceException();
                }
            }
        }

        parent::modify($checkDiff);
    }


    /**
     * Unsets cache of objects list (Must implement Cacheable)
     * @throws LogicException
     */
    public static function unsetListCache()
    {
        $instance   = new static();

        if ($instance instanceof Cacheable) {
            $cacheMask = strtolower(str_replace('\\', '_', get_class($instance))) . '_nested_list';
            $files = App::$cache->getKeys($cacheMask);

            foreach ($files as $filename) {
                unset(App::$cache[$filename]);
            }
        }

        parent::unsetListCache();
    }


    /**
     * Returns list of main categories
     * @param array|null $filter
     * @return static[]
     * @throws ReflectionException
     * @throws VelisException
     */
    public static function getMainElements(array $filter = null)
    {
        $list = [];

        static::_loadSource($filter);
        $instance = new static();

        foreach (static::$_source as $element) {
            if ($element->isMainElement() && $element->id()) {
                if (!empty($filter)) {
                    foreach ($filter as $field => $value) {
                        if (
                            (!is_array($value) && $element[$field] != $value)
                            || (is_array($value) && !in_array($element[$field], $value))
                        ) {
                            continue (2);
                        }
                    }
                }
                $list[$element->id()] = $element;
            }
        }

        uasort($list, [get_class($instance), 'compareNames']);

        return $list;
    }


    /**
     * Returns parent instance
     * @return static
     * @throws InvalidArgumentException
     */
    public function getParent()
    {
        return static::get($this->getParentId());
    }


    /**
     * Returns element path
     *
     * @param string $separator
     * @return array
     */
    public function getPath($separator = null)
    {
        $class   = get_class($this);

        $element = new $class($this->getArrayCopy());
        $path    = array(clone $element);

        do {
            $parent = $element->getParent();
            if ($parent) {
                $path[] = clone $parent;
            }
            unset($element);
            $element = $parent;
        } while ($element && !$element->isMainElement());

        if ($separator) {
            return implode($separator, array_reverse($path));
        } else {
            return array_reverse($path);
        }
    }


    /**
     * Returns true if element is connected with root
     * @return bool
     */
    public function isMainElement()
    {
        return $this->getParentId() == null;
    }


    /**
     * Returns category children
     *
     * @param bool $recursive
     * @param ParameterBag|array|null $filter
     * @return static[]
     * @throws ReflectionException
     * @throws VelisException
     */
    public function getChildren(bool $recursive = false, $filter = null): array
    {
        $children = [];
        static::_loadSource($filter);

        $instance = new static();
        $primaryKey = $this->_getPrimaryKeyField();

        foreach (static::$_source as $item) {
            if ($item->getParentId() == $this[$primaryKey] && (string)$this[$primaryKey] !== '0') {
                if (!empty($filter)) {
                    foreach ($filter as $field => $value) {
                        if (
                            (!is_array($value) && $item[$field] != $value)
                            || (is_array($value) && !in_array($item[$field], $value))
                        ) {
                            continue (2);
                        }
                    }
                }
                $children[$item[$primaryKey]] = $item;
                if ($recursive) {
                    foreach ($item->getChildren(true) as $child) {
                        if (!empty($filter)) {
                            foreach ($filter as $field => $value) {
                                if (
                                    (!is_array($value) && $child[$field] != $value)
                                    || (is_array($value) && !in_array($child[$field], $value))
                                ) {
                                    continue (2);
                                }
                            }
                        }
                        $children[$child[$primaryKey]] = $child;
                    }
                }
            }
        }

        uasort($children, [get_class($instance), 'compareNames']);

        return $children;
    }


    /**
     * Returns children id array
     *
     * @param bool $recursive
     * @return int[]
     */
    public function getChildrenIds($recursive = false)
    {
        $children = $this->getChildren($recursive);
        return static::getCollectionIds($children);
    }


    /**
     * Returns subcategory main category ($this if category is main itself)
     * @return \Velis\Model\Hierarchical
     */
    public function getMainElement()
    {
        if ($this->isMainElement()) {
            return $this;
        } else {
            return $this->getParent()->getMainElement();
        }
    }


    /**
     * Returns main category id
     * @return int
     */
    public function getMainElementId()
    {
        return $this->getMainElement()->id();
    }


    /**
     * Returns category name
     * @return string
     */
    public function getName()
    {
        return $this['name'];
    }


    /**
     * Returns category string representation
     * @return string
     */
    public function __toString()
    {
        if ($this->offsetExists('level')) {
            return $this->output(true);
        } else {
            return parent::__toString();
        }
    }


    /**
     * Converts to string
     *
     * @param bool $html use html special chars
     * @return string
     */
    public function output($html = false)
    {
        if ($html) {
            return str_repeat("&nbsp;&nbsp;&nbsp;", $this['level']) . '|&#8212;' . $this->getName();
        } else {
            return '|-' . str_repeat("--", $this['level']) . $this->getName();
        }
    }


    /**
     * Compares categories names - dedicated for sorting collections
     *
     * @param \Velis\Model\Hierarchical $element1
     * @param \Velis\Model\Hierarchical $element2
     *
     * @return int
     */
    public static function compareNames(Hierarchical $element1, Hierarchical $element2)
    {
        if ($element1->getName() == 'Inne') {
            return 1;
        } elseif ($element2->getName() == 'Inne') {
            return -1;
        } else {
            return strcasecmp($element1, $element2);
        }
    }


    /**
     * Returns elements list with nested order (depth info included as 'level' field)
     *
     * @param array|null $filter
     * @param array|null $predefinedList
     * @return static[]
     */
    public static function getNestedList(array $filter = null, array $predefinedList = null)
    {
        $instance = new static();
        $cachedList = null;
        $cacheId = null;

        if (!static::$_source && $instance instanceof Cacheable) {
            $cacheId = strtolower(str_replace('\\', '_', get_class($instance)))
                . '_nested_list_' . md5(serialize($filter)) . '_' . Lang::getLanguage();
            $cachedList = App::$cache[$cacheId];
        }

        if (!$cachedList || $predefinedList) {
            $elements = [];

            if ($predefinedList) {
                uasort($predefinedList, [get_class($instance), 'compareNames']);
            }
            $list = is_array($predefinedList) ? $predefinedList : static::getMainElements($filter);

            foreach ($list as $elem) {
                $element = clone $elem;

                $element['level'] = 0;
                $elements[$element->id()] = $element;
                $element->_getNestedChildren($elements, $filter);
            }
            static::$_source = null;

            if (!$predefinedList && $cacheId) {
                App::$cache[$cacheId] = $elements;
            }
        } else {
            $elements = $cachedList;
        }

        return $elements;
    }


    /**
     * Returns nested children with depth info
     *
     * @param array $elements
     * @param array|null $filter
     * @return void
     * @throws ReflectionException
     * @throws VelisException
     */
    protected function _getNestedChildren(array &$elements, array $filter = null)
    {
        if (!$this->offsetExists('level')) {
            $this['level'] = $this->getDepthLevel();
        }

        $children = $this->getChildren(false, $filter);

        if (sizeof($children)) {
            foreach ($children as $ch) {
                $child = clone $ch;
                $child['level'] = $this['level'] + 1;

                if (!isset($elements[$child->id()])) {
                    $elements[$child->id()] = $child;
                    $child->_getNestedChildren($elements, $filter);
                }
            }
        }
    }


    /**
     * Makes nested list from previously loaded data
     *
     * @param \Velis\Model\Hierarchical[] $list
     * @return \Velis\Model\Hierarchical[]
     */
    public static function makeNestedList($list)
    {
        static::$_source = $list;
        $nestedList = static::getNestedList();
        static::$_source = null;

        return $nestedList;
    }

    /**
     * Returns category depth level
     * @return int
     */
    public function getDepthLevel()
    {
        if ($this->isMainElement()) {
            return 0;
        } else {
            $list = static::getNestedList();
            return $list[$this->id()]['level'];
        }
    }

    /**
     * @param ParameterBag|array|null $filter
     * @param bool $nested
     * @return array
     * @throws ReflectionException
     * @throws VelisException
     */
    public static function getElementsTree($filter = null, bool $nested = false): array
    {
        $mainElements = static::getMainElements($filter);
        $items = static::getSubtreeFromCollection($mainElements, 0, $filter, $nested);

        foreach ($items as &$item) {
            if (0 === $item['parent']) {
                $item['type'] = 'main';
            }
        }

        static::$_source = null;

        return [
            'identifier' => 'id',
            'label' => 'name',
            'id' => '0',
            'name' => 'root',
            'items' => $items,
        ];
    }

    /**
     * @param static[] $collection
     * @param int|string $parentId
     * @param ParameterBag|array|null $filter
     * @param bool $nested
     * @return array
     * @throws ReflectionException
     * @throws VelisException
     */
    protected static function getSubtreeFromCollection(array $collection, $parentId, $filter = null, bool $nested = false): array
    {
        $result = [];

        foreach ($collection as $element) {
            $elementArray = [
                'name' => (string) $element,
                'title' => (string) $element,
                'value' => $element->_getPrimaryKeyField() . '-' . $element->id(),
                'parent' => $parentId,
            ];

            $elementArray = array_merge($elementArray, $element->getArrayCopy());
            $elementArray['id'] = $element->id();

            $childrenCollection = $element->getChildren(false, $filter);
            $children = static::getSubtreeFromCollection($childrenCollection, $element->id(), $filter, $nested);

            if (!empty($children)) {
                if ($nested) {
                    $elementArray['children'] = $children;
                } else {
                    $elementArray['children'] = [];
                    foreach ($children as $child) {
                        if ($child['parent'] === $elementArray['id']) {
                            $elementArray['children'][] = [
                                '_reference' => $child['id'],
                            ];
                        }

                        $result[] = $child;
                    }
                }
            }

            $result[] = $elementArray;
        }

        return $result;
    }

    /**
     * Returns elements json representation
     *
     * @param ParameterBag|array|null $filter
     * @param bool $nested
     * @return string
     * @throws ReflectionException
     * @throws VelisException
     *
     * @deprecated Use ::getElementsTree() and Output::jsonEncode() or Response::setJsonContent()
     */
    public static function getElementsJSON(array $filter = null, bool $nested = false)
    {
        $elements = static::getElementsTree($filter, $nested);

        return Output::jsonEncode($elements);
    }
}
