<?php

/*
 * This file is part of the Doctrine Behavioral Extensions package.
 * (c) Gediminas Morkevicius <gediminas.morkevicius@gmail.com> http://www.gediminasm.org
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Gedmo\Tree\Strategy;

use Doctrine\ODM\MongoDB\UnitOfWork as MongoDBUnitOfWork;
use Doctrine\Persistence\Mapping\ClassMetadata;
use Doctrine\Persistence\ObjectManager;
use Gedmo\Exception\RuntimeException;
use Gedmo\Exception\TreeLockingException;
use Gedmo\Mapping\Event\AdapterInterface;
use Gedmo\Tree\Node;
use Gedmo\Tree\Strategy;
use Gedmo\Tree\TreeListener;
use MongoDB\BSON\UTCDateTime;
use ProxyManager\Proxy\GhostObjectInterface;

/**
 * This strategy makes tree using materialized path strategy
 *
 * @author Gustavo Falco <comfortablynumb84@gmail.com>
 * @author Gediminas Morkevicius <gediminas.morkevicius@gmail.com>
 * @author <rocco@roccosportal.com>
 */
abstract class AbstractMaterializedPath implements Strategy
{
    public const ACTION_INSERT = 'insert';
    public const ACTION_UPDATE = 'update';
    public const ACTION_REMOVE = 'remove';

    /**
     * @var TreeListener
     */
    protected $listener;

    /**
     * Array of objects which were scheduled for path processes
     *
     * @var array<int, object|Node>
     */
    protected $scheduledForPathProcess = [];

    /**
     * Array of objects which were scheduled for path process.
     * This time, this array contains the objects with their ID
     * already set
     *
     * @var array<int, object|Node>
     */
    protected $scheduledForPathProcessWithIdSet = [];

    /**
     * Roots of trees which needs to be locked
     *
     * @var array<int, object|Node>
     */
    protected $rootsOfTreesWhichNeedsLocking = [];

    /**
     * Objects which are going to be inserted (set only if tree locking is used)
     *
     * @var array<int, object|Node>
     */
    protected $pendingObjectsToInsert = [];

    /**
     * Objects which are going to be updated (set only if tree locking is used)
     *
     * @var array<int, object|Node>
     */
    protected $pendingObjectsToUpdate = [];

    /**
     * Objects which are going to be removed (set only if tree locking is used)
     *
     * @var array<int, object|Node>
     */
    protected $pendingObjectsToRemove = [];

    public function __construct(TreeListener $listener)
    {
        $this->listener = $listener;
    }

    public function getName()
    {
        return Strategy::MATERIALIZED_PATH;
    }

    public function processScheduledInsertion($om, $node, AdapterInterface $ea)
    {
        $meta = $om->getClassMetadata(get_class($node));
        $config = $this->listener->getConfiguration($om, $meta->getName());
        $fieldMapping = $meta->getFieldMapping($config['path_source']);

        if ($meta->isIdentifier($config['path_source']) || 'string' === $fieldMapping['type']) {
            $this->scheduledForPathProcess[spl_object_id($node)] = $node;
        } else {
            $this->updateNode($om, $node, $ea);
        }
    }

    public function processScheduledUpdate($om, $node, AdapterInterface $ea)
    {
        $meta = $om->getClassMetadata(get_class($node));
        $config = $this->listener->getConfiguration($om, $meta->getName());
        $uow = $om->getUnitOfWork();
        $changeSet = $ea->getObjectChangeSet($uow, $node);

        if (isset($changeSet[$config['parent']]) || isset($changeSet[$config['path_source']])) {
            if (isset($changeSet[$config['path']])) {
                $originalPath = $changeSet[$config['path']][0];
            } else {
                $pathProp = $meta->getReflectionProperty($config['path']);
                $pathProp->setAccessible(true);
                $originalPath = $pathProp->getValue($node);
            }

            $this->updateNode($om, $node, $ea);
            $this->updateChildren($om, $node, $ea, $originalPath);
        }
    }

    public function processPostPersist($om, $node, AdapterInterface $ea)
    {
        $oid = spl_object_id($node);

        if ($this->scheduledForPathProcess && array_key_exists($oid, $this->scheduledForPathProcess)) {
            $this->scheduledForPathProcessWithIdSet[$oid] = $node;

            unset($this->scheduledForPathProcess[$oid]);

            if (empty($this->scheduledForPathProcess)) {
                foreach ($this->scheduledForPathProcessWithIdSet as $oid => $node) {
                    $this->updateNode($om, $node, $ea);

                    unset($this->scheduledForPathProcessWithIdSet[$oid]);
                }
            }
        }

        $this->processPostEventsActions($om, $ea, $node, self::ACTION_INSERT);
    }

    public function processPostUpdate($om, $node, AdapterInterface $ea)
    {
        $this->processPostEventsActions($om, $ea, $node, self::ACTION_UPDATE);
    }

    public function processPostRemove($om, $node, AdapterInterface $ea)
    {
        $this->processPostEventsActions($om, $ea, $node, self::ACTION_REMOVE);
    }

    public function onFlushEnd($om, AdapterInterface $ea)
    {
        $this->lockTrees($om, $ea);
    }

    public function processPreRemove($om, $node)
    {
        $this->processPreLockingActions($om, $node, self::ACTION_REMOVE);
    }

    public function processPrePersist($om, $node)
    {
        $this->processPreLockingActions($om, $node, self::ACTION_INSERT);
    }

    public function processPreUpdate($om, $node)
    {
        $this->processPreLockingActions($om, $node, self::ACTION_UPDATE);
    }

    public function processMetadataLoad($om, $meta)
    {
    }

    public function processScheduledDelete($om, $node)
    {
        $meta = $om->getClassMetadata(get_class($node));
        $config = $this->listener->getConfiguration($om, $meta->getName());

        $this->removeNode($om, $meta, $config, $node);
    }

    /**
     * Update the $node
     *
     * @param object           $node target node
     * @param AdapterInterface $ea   event adapter
     *
     * @return void
     */
    public function updateNode(ObjectManager $om, $node, AdapterInterface $ea)
    {
        $meta = $om->getClassMetadata(get_class($node));
        $config = $this->listener->getConfiguration($om, $meta->getName());
        $uow = $om->getUnitOfWork();
        $parentProp = $meta->getReflectionProperty($config['parent']);
        $parentProp->setAccessible(true);
        $parent = $parentProp->getValue($node);
        $pathProp = $meta->getReflectionProperty($config['path']);
        $pathProp->setAccessible(true);
        $pathSourceProp = $meta->getReflectionProperty($config['path_source']);
        $pathSourceProp->setAccessible(true);
        $path = (string) $pathSourceProp->getValue($node);

        // We need to avoid the presence of the path separator in the path source
        if (false !== strpos($path, $config['path_separator'])) {
            $msg = 'You can\'t use the Path separator ("%s") as a character for your PathSource field value.';

            throw new RuntimeException(sprintf($msg, $config['path_separator']));
        }

        $fieldMapping = $meta->getFieldMapping($config['path_source']);

        // default behavior: if PathSource field is a string, we append the ID to the path
        // path_append_id is true: always append id
        // path_append_id is false: never append id
        if (true === $config['path_append_id'] || ('string' === $fieldMapping['type'] && false !== $config['path_append_id'])) {
            if (method_exists($meta, 'getIdentifierValue')) {
                $identifier = $meta->getIdentifierValue($node);
            } else {
                $identifierProp = $meta->getReflectionProperty($meta->getSingleIdentifierFieldName());
                $identifierProp->setAccessible(true);
                $identifier = $identifierProp->getValue($node);
            }

            $path .= '-'.$identifier;
        }

        if ($parent) {
            // Ensure parent has been initialized in the case where it's a proxy
            $om->initializeObject($parent);

            $changeSet = $uow->isScheduledForUpdate($parent) ? $ea->getObjectChangeSet($uow, $parent) : false;
            $pathOrPathSourceHasChanged = $changeSet && (isset($changeSet[$config['path_source']]) || isset($changeSet[$config['path']]));

            if ($pathOrPathSourceHasChanged || !$pathProp->getValue($parent)) {
                $this->updateNode($om, $parent, $ea);
            }

            $parentPath = $pathProp->getValue($parent);
            // if parent path not ends with separator
            if ($parentPath[strlen($parentPath) - 1] !== $config['path_separator']) {
                // add separator
                $path = $pathProp->getValue($parent).$config['path_separator'].$path;
            } else {
                // don't add separator
                $path = $pathProp->getValue($parent).$path;
            }
        }

        if ($config['path_starts_with_separator'] && (strlen($path) > 0 && $path[0] !== $config['path_separator'])) {
            $path = $config['path_separator'].$path;
        }

        if ($config['path_ends_with_separator'] && ($path[strlen($path) - 1] !== $config['path_separator'])) {
            $path .= $config['path_separator'];
        }

        $pathProp->setValue($node, $path);
        $changes = [
            $config['path'] => [null, $path],
        ];

        $pathHash = null;

        if (isset($config['path_hash'])) {
            $pathHash = md5($path);
            $pathHashProp = $meta->getReflectionProperty($config['path_hash']);
            $pathHashProp->setAccessible(true);
            $pathHashProp->setValue($node, $pathHash);
            $changes[$config['path_hash']] = [null, $pathHash];
        }

        if (isset($config['root'])) {
            $root = null;

            // Define the root value by grabbing the top of the current path
            $rootFinderPath = explode($config['path_separator'], $path);
            $rootIndex = $config['path_starts_with_separator'] ? 1 : 0;
            $root = $rootFinderPath[$rootIndex];

            // If it is an association, then make it an reference
            // to the entity
            if ($meta->hasAssociation($config['root'])) {
                $rootClass = $meta->getAssociationTargetClass($config['root']);
                $root = $om->getReference($rootClass, $root);
            }

            $rootProp = $meta->getReflectionProperty($config['root']);
            $rootProp->setAccessible(true);
            $rootProp->setValue($node, $root);
            $changes[$config['root']] = [null, $root];
        }

        if (isset($config['level'])) {
            $level = substr_count($path, $config['path_separator']);
            $levelProp = $meta->getReflectionProperty($config['level']);
            $levelProp->setAccessible(true);
            $levelProp->setValue($node, $level);
            $changes[$config['level']] = [null, $level];
        }

        if (!$uow instanceof MongoDBUnitOfWork) {
            $ea->setOriginalObjectProperty($uow, $node, $config['path'], $path);
            $uow->scheduleExtraUpdate($node, $changes);
        } else {
            $ea->recomputeSingleObjectChangeSet($uow, $meta, $node);
        }
        if (isset($config['path_hash'])) {
            $ea->setOriginalObjectProperty($uow, $node, $config['path_hash'], $pathHash);
        }
    }

    /**
     * Update node's children
     *
     * @param object $node
     * @param string $originalPath
     *
     * @return void
     */
    public function updateChildren(ObjectManager $om, $node, AdapterInterface $ea, $originalPath)
    {
        $meta = $om->getClassMetadata(get_class($node));
        $config = $this->listener->getConfiguration($om, $meta->getName());
        $children = $this->getChildren($om, $meta, $config, $originalPath);

        foreach ($children as $child) {
            $this->updateNode($om, $child, $ea);
        }
    }

    /**
     * Process pre-locking actions
     *
     * @param ObjectManager $om
     * @param object        $node
     * @param string        $action
     *
     * @return void
     */
    public function processPreLockingActions($om, $node, $action)
    {
        $meta = $om->getClassMetadata(get_class($node));
        $config = $this->listener->getConfiguration($om, $meta->getName());

        if ($config['activate_locking']) {
            $parentProp = $meta->getReflectionProperty($config['parent']);
            $parentProp->setAccessible(true);
            $parentNode = $node;

            while (($parent = $parentProp->getValue($parentNode)) !== null) {
                $parentNode = $parent;
            }

            // In some cases, the parent could be a not initialized proxy. In this case, the
            // "lockTime" field may NOT be loaded yet and have null instead of the date.
            // We need to be sure that this field has its real value
            if ($parentNode !== $node && $parentNode instanceof GhostObjectInterface) {
                $parentNode->initializeProxy();
            }

            // If tree is already locked, we throw an exception
            $lockTimeProp = $meta->getReflectionProperty($config['lock_time']);
            $lockTimeProp->setAccessible(true);
            $lockTime = $lockTimeProp->getValue($parentNode);

            if (null !== $lockTime) {
                $lockTime = $lockTime instanceof UTCDateTime ? $lockTime->toDateTime()->getTimestamp() : $lockTime->getTimestamp();
            }

            if (null !== $lockTime && ($lockTime >= (time() - $config['locking_timeout']))) {
                $msg = 'Tree with root id "%s" is locked.';
                $id = $meta->getIdentifierValue($parentNode);

                throw new TreeLockingException(sprintf($msg, $id));
            }

            $this->rootsOfTreesWhichNeedsLocking[spl_object_id($parentNode)] = $parentNode;

            $oid = spl_object_id($node);

            switch ($action) {
                case self::ACTION_INSERT:
                    $this->pendingObjectsToInsert[$oid] = $node;

                    break;
                case self::ACTION_UPDATE:
                    $this->pendingObjectsToUpdate[$oid] = $node;

                    break;
                case self::ACTION_REMOVE:
                    $this->pendingObjectsToRemove[$oid] = $node;

                    break;
                default:
                    throw new \InvalidArgumentException(sprintf('"%s" is not a valid action.', $action));
            }
        }
    }

    /**
     * Process pre-locking actions
     *
     * @param object $node
     * @param string $action
     *
     * @return void
     */
    public function processPostEventsActions(ObjectManager $om, AdapterInterface $ea, $node, $action)
    {
        $meta = $om->getClassMetadata(get_class($node));
        $config = $this->listener->getConfiguration($om, $meta->getName());

        if ($config['activate_locking']) {
            switch ($action) {
                case self::ACTION_INSERT:
                    unset($this->pendingObjectsToInsert[spl_object_id($node)]);

                    break;
                case self::ACTION_UPDATE:
                    unset($this->pendingObjectsToUpdate[spl_object_id($node)]);

                    break;
                case self::ACTION_REMOVE:
                    unset($this->pendingObjectsToRemove[spl_object_id($node)]);

                    break;
                default:
                    throw new \InvalidArgumentException(sprintf('"%s" is not a valid action.', $action));
            }

            if (empty($this->pendingObjectsToInsert) && empty($this->pendingObjectsToUpdate)
                && empty($this->pendingObjectsToRemove)) {
                $this->releaseTreeLocks($om, $ea);
            }
        }
    }

    /**
     * Remove node and its children
     *
     * @param ObjectManager        $om
     * @param ClassMetadata        $meta   Metadata
     * @param array<string, mixed> $config config
     * @param object               $node   node to remove
     *
     * @return void
     */
    abstract public function removeNode($om, $meta, $config, $node);

    /**
     * Returns children of the node with its original path
     *
     * @param ObjectManager        $om
     * @param ClassMetadata        $meta         Metadata
     * @param array<string, mixed> $config       config
     * @param string               $originalPath original path of object
     *
     * @return array<int, object>|\Traversable<int, object>
     */
    abstract public function getChildren($om, $meta, $config, $originalPath);

    /**
     * Locks all needed trees
     *
     * @return void
     */
    protected function lockTrees(ObjectManager $om, AdapterInterface $ea)
    {
        // Do nothing by default
    }

    /**
     * Releases all trees which are locked
     *
     * @return void
     */
    protected function releaseTreeLocks(ObjectManager $om, AdapterInterface $ea)
    {
        // Do nothing by default
    }
}
