<?php
/**
 * Tree class.
 *
 * Represents an node of a tree and provides iterator abilities.
 *
 * This software is free software; you can redistribute it and/or
 * modify it under the terms of the GNU Lesser General Public
 * License version 3 as published by the Free Software Foundation
 *
 * This library is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
 * Lesser General Public License for more details.
 *
 * @category   PHProjekt
 * @package    Phprojekt
 * @subpackage Tree
 * @copyright  Copyright (c) 2010 Mayflower GmbH (http://www.mayflower.de)
 * @license    LGPL v3 (See LICENSE file)
 * @link       http://www.phprojekt.com
 * @since      File available since Release 6.0
 * @version    Release: 6.0.5
 * @author     David Soria Parra <david.soria_parra@mayflower.de>
 */

/**
 * Tree class.
 *
 * Represents an node of a tree and provides iterator abilities.
 *
 * @category   PHProjekt
 * @package    Phprojekt
 * @subpackage Tree
 * @copyright  Copyright (c) 2010 Mayflower GmbH (http://www.mayflower.de)
 * @license    LGPL v3 (See LICENSE file)
 * @link       http://www.phprojekt.com
 * @since      File available since Release 6.0
 * @version    Release: 6.0.5
 * @author     David Soria Parra <david.soria_parra@mayflower.de>
 */
class Phprojekt_Tree_Node_Database implements IteratorAggregate
{
    /**
     * Name for use with the cache.
     */
    const CACHE_NAME = 'Phprojekt_Tree_Node_Database_setup';

    /**
     * The char that separates a tree path in the database.
     * It should not be edited, if you don't initialize a complete empty tree, as no conversion is done.
     */
    const NODE_SEPARATOR = '/';

    /**
     * Parent Node.
     *
     * @var Phprojekt_Tree_Node
     */
    protected $_parentNode = null;

    /**
     * Array of child nodes objects.
     *
     * @var array
     */
    protected $_children = array();

    /**
     * The active record pattern used to determine the table where
     * our actual tree data is stored as well as receiving the additional
     * columns from the table.
     *
     * @var Phprojekt_ActiveRecord_Abstract
     */
    protected $_activeRecord = null;

    /**
     * The id of the initial requested element.
     * We dont use the id here because id is received by the active record pattern and is used
     * to determine if a node was sucessfull received.
     * The requested id itself does nothing say about the current storage status of the node.
     *
     * @var integer
     */
    protected $_requestedId = null;

    /**
     * If this is the root node, it holds an simple list of all nodes in the tree.
     * This is used to do fast lookups on a given id.
     *
     * @var array
     */
    protected $_index = array();


    /**
     * Initialize a new node and sets the tree the node belongs to.
     *
     * @param stdClass $activeRecord The Object that holds the tree.
     * @param integer  $id           The requested node, that will be the root node.
     *
     * @return void
     */
    public function __construct($activeRecord, $id = null)
    {
        $this->_activeRecord = $activeRecord;

        if (null !== $id) {
            $this->_requestedId = $id;
        } else if (isset($activeRecord->id)) {
            $this->_requestedId = $activeRecord->id;
        }
    }

    /**
     * Reinialize the tree and reset all internal information.
     *
     * @see delete()
     *
     * @return void
     */
    protected function _initialize()
    {
        $this->_requestedId  = null;
        $this->_parentNode   = null;
        $this->_index        = array();
        $this->_children     = array();
        $this->_activeRecord = null;
    }

    /**
     * Checks if the tree was setup yet, by checking of the node has an id.
     *
     * @return boolean
     */
    public function isSetup()
    {
        return null !== $this->id;
    }

    /**
     * Try to receive a tree/subtree from the database using the
     * active record object to get the name of the tree table.
     * If the requested id is not set using the constructor this method
     * usually fails throwing an exception.
     *
     * @param Phprojekt_Filter_Interface $filter A filter to chain.
     *
     * @throws Phprojekt_Tree_Node_Exception If no id was requested (see constructor)
     *
     * @return Phprojekt_Tree_Node_Database An instance of Phprojekt_Tree_Node_Database.
     */
    public function setup(Phprojekt_Filter_Abstract $filter = null)
    {
        // @todo: fix this, must be possible with requestid === null
        if (null === $this->_requestedId) {
            throw new Phprojekt_Tree_Node_Exception('You have to set a requested treeid in the constructor');
        }

        $cache = Phprojekt::getInstance()->getCache();
        if ($this->_requestedId == 0) {
            return $this;
        } else if ($this->_requestedId > 1) {
            if (!($object = $cache->load(self::CACHE_NAME))) {
                $tree   = new Phprojekt_Tree_Node_Database($this->_activeRecord, 1);
                $object = $tree->setup();
            }

            $tree = $object->getNodeById($this->_requestedId);
            if (null === $tree) {
                throw new Phprojekt_Tree_Node_Exception('Requested node not found');
            }
            $tree->_parentNode = null;

            return $this->applyRights($tree);
        } else if (!($object = $cache->load(self::CACHE_NAME))) {
            $database = $this->getActiveRecord()->getAdapter();
            $table    = $this->getActiveRecord()->getTableName();
            $select   = $database->select();

            $select->from($table, 'path')
                   ->where(sprintf('id = %d', (int) $this->_requestedId))
                   ->limit(1);

            if (null !== $filter) {
                $filter->filter($select, $this->getActiveRecord()->getAdapter());
            }

            $rootPath = $database->fetchOne($select);

            if (null === $rootPath) {
                throw new Phprojekt_Tree_Node_Exception('Requested node not found');
            }

            // Get all the projects
            $where  = sprintf("(%s OR id = %d)", $database->quoteInto("path LIKE ?", $rootPath . '%'), (int) $this->id);
            $select = $database->select();
            $select->from($table)
                   ->where($where)
                   ->order('path');
            $treeData = $select->query()->fetchAll(Zend_Db::FETCH_CLASS);
            foreach ($treeData as $index => $record) {
                foreach ($record as $key => $value) {
                    $newKey = Phprojekt_ActiveRecord_Abstract::convertVarFromSql($key);
                    $treeData[$index]->$newKey = $value;
                }
            }

            foreach ($treeData as $record) {
                $node = null;
                if ($record->id == $this->_requestedId) {
                    $node                = $this;
                    $this->_activeRecord = $record;
                } elseif (array_key_exists($record->projectId, $this->_index)) {
                    $node = new Phprojekt_Tree_Node_Database($record);
                    $this->_index[$record->projectId]->appendNode($node);
                }

                if (null !== $node) {
                    $this->_index[$node->id] = $node;
                }
            }

            $object = $this;
            $cache->save($this, self::CACHE_NAME);

            // Delete the session for re-calculate the rights
            $sessionName     = 'Phprojekt_Tree_Node_Database-applyRights';
            $rightsNamespace = new Zend_Session_Namespace($sessionName);
            $rightsNamespace->unsetAll();
        }

        return $this->applyRights($object);
    }

    /**
     * Delete the projects where the user don't have access.
     *
     * @param Phprojekt_Tree_Node_Database $object Tree class.
     *
     * @return Phprojekt_Tree_Node_Database The tree class with only the allowed nodes.
     */
    public function applyRights($object)
    {
        $sessionName     = 'Phprojekt_Tree_Node_Database-applyRights';
        $rightsNamespace = new Zend_Session_Namespace($sessionName);

        // Get the itemRights relation
        if (isset($rightsNamespace->rights)) {
            $rights = $rightsNamespace->rights;
        } else {
            $database = $this->getActiveRecord()->getAdapter();
            $where    = sprintf("module_id = %d AND user_id = %d AND access > 0",
                Phprojekt_Module::getId($this->getActiveRecord()->getModelName()), Phprojekt_Auth::getUserId());
            $select = $database->select();
            $select->from('item_rights', 'item_id')
                   ->where($where);
            $results = $select->query()->fetchAll();
            $rights  = array();
            foreach ($results as $result) {
                $rights[] = $result['item_id'];
            }
            $rightsNamespace->rights = $rights;
        }

        // Delete the projects where the user don't have access
        foreach ($object as $index => $tree) {
            if (!in_array($tree->id, $rights)) {
                if ($tree->isRootNodeForCurrentTree()) {
                    throw new Phprojekt_Tree_Node_Exception('Requested node not found');
                } else {
                    $this->deleteNode($object, $tree->id);
                }
            }
        }

        return $object;
    }

    /**
     * Delete a children node.
     *
     * @param Phprojekt_Tree_Node_Database $object Tree class.
     * @param integer                      $id     IF for delete.
     *
     * @return void
     */
    public function deleteNode($object, $id)
    {
        if (isset($object->_children[$id])) {
            unset($object->_children[$id]);
        } else {
            foreach ($object->_children as $children) {
                $this->deleteNode($children, $id);
            }
        }
    }

    /**
     * Returns active record used to display the tree.
     *
     * @return Phprojekt_ActiveRecord_Abstract An instance of Phprojekt_ActiveRecord_Abstract.
     */
    public function getActiveRecord()
    {
        if (!$this->_activeRecord instanceof Phprojekt_ActiveRecord_Abstract) {
            $model = Phprojekt_Loader::getModel('Project', 'Project');
            $this->_activeRecord = $model->find($this->_requestedId);
        }

        return $this->_activeRecord;
    }

    /**
     * Move upstairs until reach the root node and returns them.
     *
     * @return Phprojekt_Tree_Node_Database An instance of Phprojekt_Tree_Node_Database.
     */
    public function getRootNode()
    {
        if ($this->isRootNodeForCurrentTree()) {
            return $this;
        } else {
            return $this->getParentNode()->getRootNode();
        }
    }

    /**
     * Append a new node.
     * If it the new was not yet stored in the database (id === null) it's inserted.
     *
     * @param Phprojekt_Tree_Node_Database $node The node to append.
     *
     * @throws Phprojekt_Tree_Node_Exception If object has no current active record.
     *
     * @return Phprojekt_Tree_Node_Database An instance of Phprojekt_Tree_Node_Database.
     */
    public function appendNode(Phprojekt_Tree_Node_Database $node)
    {
        if (null !== $node->_activeRecord) {
            if (null === $node->id || $node->id == 0) {
                $node->_activeRecord->projectId = (int) $this->id;
                $node->_activeRecord->path      = sprintf('%s%s%s', $this->path, $this->id, self::NODE_SEPARATOR);
                $node->_activeRecord->save();
                self::deleteCache();
            }

            $node->setParentNode($this);
            $this->_children[$node->id] = $node;

            return $this;
        }
    }

    /**
     * Recursive delete children.
     *
     * @param array &$children Array of children to delete.
     *
     * @return void
     */
    private function _deleteChildren(&$children)
    {
        foreach ($children as $k => $child) {
            if ($child->hasChildren()) {
                $this->_deleteChildren($child->getChildren());
            } else {
                unset ($children[$k]);
            }
        }
    }

    /**
     * Delete a node an all subnodes.
     * ! NOTE this method uses transaction locking.
     *
     * @throws Zend_Db_Exception If node is not stored to database or was not received yet.
     *
     * @return void
     */
    public function delete()
    {
        if (null === $this->id) {
            throw new Phprojekt_Tree_Node_Exception('Node not received or stored'
                . ' from/to the database yet');
        }

        if ($this->id == 1) {
            throw new Phprojekt_Tree_Node_Exception('You can not delete the Invisible Root');
        }

        $table    = $this->getActiveRecord()->getTableName();
        $database = Phprojekt::getInstance()->getDb();
        $database->delete($table, $database->quoteInto('path LIKE ?', $this->path . '%'));
        $children = $this->getChildren();
        $this->_deleteChildren($children);
        $this->_initialize();
        self::deleteCache();
    }

    /**
     * Set the parent node.
     * !NOTE this method is somewhat dumb, it doesnot check if
     * it is a child of the given parent node.
     * You can messup the tree using the method not carefully.
     *
     * @param Phprojekt_Tree_Node_Database $node Parent node.
     *
     * @return Phprojekt_Tree_Node_Database An instance of Phprojekt_Tree_Node_Database.
     */
    public function setParentNode(Phprojekt_Tree_Node_Database $node)
    {
        if ($node->id != $this->_activeRecord->projectId) {
            // Update move
            $this->getActiveRecord()->projectId = (int) $node->id;
            $node = $this->_rebuildPaths($this, $node->path . $node->id . self::NODE_SEPARATOR);
            $node->getActiveRecord()->save();
            self::deleteCache();
        }

        $this->_parentNode = $node;

        return $this;
    }

    /**
     * Rebuild the paths of a subtree.
     *
     * @param Phprojekt_Tree_Node_Database $node     Node to rebuild.
     * @param string                       $basePath Path of the parent.
     *
     * @return void
     */
    protected function _rebuildPaths(Phprojekt_Tree_Node_Database $node, $basePath)
    {
        if ($node->_activeRecord->path != $basePath) {
            $node->getActiveRecord()->path = $basePath;

            foreach ($node->getChildren() as $id => $child) {
                $node->_children[$id] = $this->_rebuildPaths($child, $basePath . $node->id . self::NODE_SEPARATOR);

                $this->getRootNode()->_index[$child->id] = $node->_children[$id];
                $node->_children[$id]->getActiveRecord()->parentSave();
                self::deleteCache();
            }
        }

        return $node;
    }

    /**
     * Overwrite getter to access protected properties as read-only.
     *
     * @param string $key Identifier
     *
     * @return mixed Value of the var.
     */
    public function __get($key)
    {
        switch ($key) {
            case 'parentNode':
                return $this->getParentNode();
                break;
            default:
                if (null !== $this->_activeRecord) {
                    return $this->_activeRecord->$key;
                }
                break;
        }

        return null;
    }

    /**
     * Pass all sets to the active record.
     *
     * @param integer $key   Identifier.
     * @param mixed   $value Value.
     *
     * @return void
     */
    public function __set($key, $value)
    {
        if (null !== $this->_activeRecord) {
            // Donīt allow to set the tree dependent stuff
            if (!in_array($key, array('id', 'path'))) {
                $this->getActiveRecord()->$key = $value;
            }
        }
    }

    /**
     * Overwrite isset.
     *
     * @param string $key Identifier.
     *
     * @return boolean True if exists.
     */
    public function __isset($key)
    {
        return array_key_exists($key, get_object_vars($this)) || isset($this->getActiveRecord()->$key);
    }

    /**
     * Overwrite calls.
     *
     * Act as a proxy for the active record.
     *
     * @param string $name      Name of the function.
     * @param array  $arguments Arguments.
     *
     * @return mixed
     */
    public function __call($name, $arguments)
    {
        if (method_exists($this, $name)) {
            return call_user_func_array(array($this, $name), $this->getActiveRecord(), $arguments);
        }

        return false;
    }

    /**
     * Returns a node from the current tree.
     *
     * @param integer $id ID of the node to receive.
     *
     * @return Phprojekt_Tree_Node_Database An instance of Phprojekt_Tree_Node_Database.
     */
    public function getNodeById($id)
    {
        return $this->getRootNode()->_getFromIndex($id);
    }

    /**
     * Returns a node from the tree intern index,
     * that is maintained by the root node.
     *
     * @param integer $id ID of the node to receive.
     *
     * @return Phprojekt_Tree_Node_Database|null An instance of Phprojekt_Tree_Node_Database.
     */
    protected function _getFromIndex($id)
    {
        if ($this->isRootNodeForCurrentTree()) {
            if (array_key_exists($id, $this->_index)) {
                return $this->_index[$id];
            }
        }

        return null;
    }

    /**
     * Check if the current node is the root node of a received tree.
     * This does not mean that its an absolute root node.
     * Use isRootNode to check this.
     *
     * @return boolean
     */
    public function isRootNodeForCurrentTree()
    {
        return null === $this->_parentNode;
    }

    /**
     * Returns true if node is a root node.
     *
     * @return boolean
     */
    public function isRootNode()
    {
        return null === $this->projectId;
    }

    /**
     * Implementation of IteratorAggregate::getIterator.
     * Makes possible to use nodes with iterators.
     *
     * @return RecursiveIteratorIterator
     */
    public function getIterator()
    {
        return new RecursiveIteratorIterator(new Phprojekt_Tree_Node_Iterator($this),
            RecursiveIteratorIterator::SELF_FIRST);
    }

    /**
     * Returns the parent node of that node or null if the node doesnt have a parent.
     *
     * @return null|Phprojekt_Tree_Node
     */
    public function getParentNode()
    {
        return $this->_parentNode;
    }

    /**
     * Returns the depth of the current node relativ to the tree it belongs to.
     * The lowest number is 0 (root node).
     *
     * @return integer Depth.
     */
    public function getDepth()
    {
        // We need to count the tree path ourself and NOT call getDepth on the
        // root node, as if we can be the tree and we will call us into a loop
        return substr_count($this->path, self::NODE_SEPARATOR)
            - substr_count($this->getRootNode()->path, self::NODE_SEPARATOR);
    }

    /**
     * Returns a formatted string for display purpose.
     *
     * @param string Name of property to include in the output.
     *
     * @return string Name with "...." x depth.
     */
    public function getDepthDisplay($value)
    {
        return str_repeat('....', $this->getDepth()) . $this->$value;
    }

    /**
     * Returns an array of child nodes of the node.
     *
     * @return array Array of child.
     */
    public function getChildren()
    {
        return $this->_children;
    }

    /**
     * Returns true if the node has child nodes.
     *
     * @return boolean
     */
    public function hasChildren()
    {
        return count($this->_children) > 0;
    }

    /**
     * Returns the first node of a tree.
     * This also resets the internal pointer of child entries.
     * If the node doesn't have any children NULL is returned.
     *
     * @return null|Phprojekt_Tree_Node
     */
    public function getFirstChild()
    {
        if ($this->hasChildren()) {
            return reset($this->_children);
        }

        return null;
    }

    /**
     * Delete the tree cache.
     *
     * @return void;
     */
    public static function deleteCache()
    {
        $cache = Phprojekt::getInstance()->getCache();
        $cache->remove(self::CACHE_NAME);
    }
}
