<?php

namespace Jackalope;

use PHPCR\Util\PathHelper;
use Exception;
use PHPCR\RepositoryInterface;
use PHPCR\SessionInterface;
use PHPCR\SimpleCredentials;
use PHPCR\CredentialsInterface;
use PHPCR\PathNotFoundException;
use PHPCR\ItemNotFoundException;
use PHPCR\ItemExistsException;
use PHPCR\RepositoryException;
use PHPCR\UnsupportedRepositoryOperationException;
use InvalidArgumentException;
use PHPCR\Security\AccessControlException;
use Jackalope\ImportExport\ImportExport;
use Jackalope\Transport\TransportInterface;
use Jackalope\Transport\TransactionInterface;
use Traversable;

/**
 * {@inheritDoc}
 *
 * Jackalope adds the SessionOption concept to handle session specific tweaking
 * and optimization. We distinguish between options that are purely
 * optimization but do not affect the behaviour and those that are change the
 * behaviour.
 *
 * @license http://www.apache.org/licenses Apache License Version 2.0, January 2004
 * @license http://opensource.org/licenses/MIT MIT License
 *
 * @api
 */
class Session implements SessionInterface
{
    /**
     * Constant for setSessionOption to manage the fetch depth.
     *
     * This option is used to set the depth with which nodes should be fetched from the backend to optimize
     * performance when you know you will need the child nodes.
     */
    const OPTION_FETCH_DEPTH = 'jackalope.fetch_depth';

    /**
     * Constant for setSessionOption to manage whether nodes having mix:lastModified should automatically be updated.
     *
     * Disable if you want to manually control this information, e.g. in a PHPCR-ODM listener.
     */
    const OPTION_AUTO_LASTMODIFIED = 'jackalope.auto_lastmodified';

    /**
     * A registry for all created sessions to be able to reference them by id in
     * the stream wrapper for lazy loading binary properties.
     *
     * Keys are spl_object_hash'es for the sessions which are the values
     *
     * @var array
     */
    protected static $sessionRegistry = [];

    /**
     * The factory to instantiate objects
     *
     * @var FactoryInterface
     */
    protected $factory;

    /**
     * @var Repository
     */
    protected $repository;

    /**
     * @var Workspace
     */
    protected $workspace;

    /**
     * @var ObjectManager
     */
    protected $objectManager;

    /**
     * @var SimpleCredentials
     */
    protected $credentials;

    /**
     * Whether this session is in logged out state and can not be used anymore
     *
     * @var bool
     */
    protected $logout = false;

    /**
     * The namespace registry.
     *
     * It is only used to check prefixes and at setup. Session namespace remapping must be handled locally.
     *
     * @var NamespaceRegistry
     */
    protected $namespaceRegistry;

    /**
     * List of local namespaces
     *
     * TODO: implement local namespace rewriting
     * see jackrabbit-spi-commons/src/main/java/org/apache/jackrabbit/spi/commons/conversion/PathParser.java and friends
     * for how this is done in jackrabbit
     */
    //protected $localNamespaces;

    /** Creates a session
     *
     * Builds the corresponding workspace instance
     *
     * @param FactoryInterface  $factory       the object factory
     * @param Repository        $repository
     * @param string            $workspaceName the workspace name that is used
     * @param SimpleCredentials $credentials   the credentials that where
     *      used to log in, in order to implement Session::getUserID()
     *      if they are null, getUserID returns null
     * @param TransportInterface $transport the transport implementation
     */
    public function __construct(FactoryInterface $factory, Repository $repository, $workspaceName, SimpleCredentials $credentials = null, TransportInterface $transport)
    {
        $this->factory = $factory;
        $this->repository = $repository;
        $this->objectManager = $this->factory->get(ObjectManager::class, [$transport, $this]);
        $this->workspace = $this->factory->get(Workspace::class, [$this, $this->objectManager, $workspaceName]);
        $this->credentials = $credentials;
        $this->namespaceRegistry = $this->workspace->getNamespaceRegistry();

        self::registerSession($this);

        $transport->setNodeTypeManager($this->workspace->getNodeTypeManager());
    }

    /**
     * {@inheritDoc}
     *
     * @api
     */
    public function getRepository()
    {
        return $this->repository;
    }

    /**
     * {@inheritDoc}
     *
     * @api
     */
    public function getUserID()
    {
        if (null === $this->credentials) {
            return null;
        }

        return $this->credentials->getUserID(); //TODO: what if its not simple credentials? what about anonymous login?
    }

    /**
     * {@inheritDoc}
     *
     * @api
     */
    public function getAttributeNames()
    {
        if (null === $this->credentials) {
            return [];
        }

        return $this->credentials->getAttributeNames();
    }

    /**
     * {@inheritDoc}
     *
     * @api
     */
    public function getAttribute($name)
    {
        if (null === $this->credentials) {
            return null;
        }

        return $this->credentials->getAttribute($name);
    }

    /**
     * {@inheritDoc}
     *
     * @api
     */
    public function getWorkspace()
    {
        return $this->workspace;
    }

    /**
     * {@inheritDoc}
     *
     * @api
     */
    public function getRootNode()
    {
        return $this->getNode('/');
    }

    /**
     * {@inheritDoc}
     *
     * @api
     */
    public function impersonate(CredentialsInterface $credentials)
    {
        throw new UnsupportedRepositoryOperationException('Not supported');
    }

    /**
     * {@inheritDoc}
     *
     * @api
     */
    public function getNodeByIdentifier($id)
    {
        return $this->objectManager->getNodeByIdentifier($id);
    }

    /**
     * {@inheritDoc}
     *
     * @api
     */
    public function getNodesByIdentifier($ids)
    {
        if (! is_array($ids) && ! $ids instanceof Traversable) {
            $hint = is_object($ids) ? get_class($ids) : gettype($ids);
            throw new InvalidArgumentException("Not a valid array or Traversable: $hint");
        }

        return $this->objectManager->getNodesByIdentifier($ids);
    }

    /**
     * {@inheritDoc}
     *
     * @api
     */
    public function getItem($absPath)
    {
        if (! is_string($absPath) || strlen($absPath) === 0 || '/' !== $absPath[0]) {
            throw new PathNotFoundException('It is forbidden to call getItem on session with a relative path');
        }

        if ($this->nodeExists($absPath)) {
            return $this->getNode($absPath);
        }

        return $this->getProperty($absPath);
    }

    /**
     * {@inheritDoc}
     *
     * @api
     */
    public function getNode($absPath, $depthHint = -1)
    {
        if (-1 !== $depthHint) {
            $depth = $this->getSessionOption(self::OPTION_FETCH_DEPTH);
            $this->setSessionOption(self::OPTION_FETCH_DEPTH, $depthHint);
        }

        try {
            $node = $this->objectManager->getNodeByPath($absPath);
            if (isset($depth)) {
                $this->setSessionOption(self::OPTION_FETCH_DEPTH, $depth);
            }

            return $node;
        } catch (ItemNotFoundException $e) {
            if (isset($depth)) {
                $this->setSessionOption(self::OPTION_FETCH_DEPTH, $depth);
            }
            throw new PathNotFoundException($e->getMessage(), $e->getCode(), $e);
        }
    }

    /**
     * {@inheritDoc}
     *
     * @api
     */
    public function getNodes($absPaths)
    {
        if (! is_array($absPaths) && ! $absPaths instanceof Traversable) {
            $hint = is_object($absPaths) ? get_class($absPaths) : gettype($absPaths);
            throw new InvalidArgumentException("Not a valid array or Traversable: $hint");
        }

        return $this->objectManager->getNodesByPath($absPaths);
    }

    /**
     * {@inheritDoc}
     *
     * @api
     */
    public function getProperty($absPath)
    {
        try {
            return $this->objectManager->getPropertyByPath($absPath);
        } catch (ItemNotFoundException $e) {
            throw new PathNotFoundException($e->getMessage(), $e->getCode(), $e);
        }
    }

    public function getProperties($absPaths)
    {
        if (! is_array($absPaths) && ! $absPaths instanceof Traversable) {
            $hint = is_object($absPaths) ? get_class($absPaths) : gettype($absPaths);
            throw new InvalidArgumentException("Not a valid array or Traversable: $hint");
        }

        return $this->objectManager->getPropertiesByPath($absPaths);
    }

    /**
     * {@inheritDoc}
     *
     * @api
     */
    public function itemExists($absPath)
    {
        if ($absPath === '/') {
            return true;
        }

        return $this->nodeExists($absPath) || $this->propertyExists($absPath);
    }

    /**
     * {@inheritDoc}
     *
     * @api
     */
    public function nodeExists($absPath)
    {
        if ($absPath === '/') {
            return true;
        }

        try {
            //OPTIMIZE: avoid throwing and catching errors would improve performance if many node exists calls are made
            //would need to communicate to the lower layer that we do not want exceptions
            $this->objectManager->getNodeByPath($absPath);
        } catch (ItemNotFoundException $e) {
            return false;
        }

        return true;
    }

    /**
     * {@inheritDoc}
     *
     * @api
     */
    public function propertyExists($absPath)
    {
        try {
            //OPTIMIZE: avoid throwing and catching errors would improve performance if many node exists calls are made
            //would need to communicate to the lower layer that we do not want exceptions
            $this->getProperty($absPath);
        } catch (PathNotFoundException $e) {
            return false;
        }

        return true;
    }

    /**
     * {@inheritDoc}
     *
     * @api
     */
    public function move($srcAbsPath, $destAbsPath)
    {
        try {
            $parent = $this->objectManager->getNodeByPath(PathHelper::getParentPath($destAbsPath));
        } catch (ItemNotFoundException $e) {
            throw new PathNotFoundException("Target path can not be found: $destAbsPath", $e->getCode(), $e);
        }

        if ($parent->hasNode(PathHelper::getNodeName($destAbsPath))) {
            // TODO same-name siblings
            throw new ItemExistsException('Target node already exists at '.$destAbsPath);
        }

        if ($parent->hasProperty(PathHelper::getNodeName($destAbsPath))) {
            throw new ItemExistsException('Target property already exists at '.$destAbsPath);
        }

        $this->objectManager->moveNode($srcAbsPath, $destAbsPath);
    }

    /**
     * {@inheritDoc}
     *
     * @api
     */
    public function removeItem($absPath)
    {
        $item = $this->getItem($absPath);
        $item->remove();
    }

    /**
     * {@inheritDoc}
     *
     * Wraps the save operation into a transaction if transactions are enabled
     * but we are not currently inside a transaction and rolls back on error.
     *
     * If transactions are disabled, errors on save can lead to partial saves
     * and inconsistent data.
     *
     * @api
     */
    public function save()
    {
        if ($this->getTransport() instanceof TransactionInterface) {
            try {
                $utx = $this->workspace->getTransactionManager();
            } catch (UnsupportedRepositoryOperationException $e) {
                // user transactions where disabled for this session, do no automatic transaction.
            }
        }

        if (isset($utx) && !$utx->inTransaction()) {
            // do the operation in a short transaction
            $utx->begin();
            try {
                $this->objectManager->save();
                $utx->commit();
            } catch (Exception $e) {
                // if anything goes wrong, rollback this mess
                try {
                    $utx->rollback();
                } catch (Exception $rollbackException) {
                    // ignore this exception
                }
                // but do not eat this exception
                throw $e;
            }
        } else {
            $this->objectManager->save();
        }
    }

    /**
     * {@inheritDoc}
     *
     * @api
     */
    public function refresh($keepChanges)
    {
        $this->objectManager->refresh($keepChanges);
    }

    /**
     * Jackalope specific hack to drop the state of the current session
     *
     * Removes all cached objects, planned changes etc without making the
     * objects aware of it. Was done as a cheap replacement for refresh
     * in testing.
     *
     * @deprecated: this will screw up major, as the user of the api can still have references to nodes. USE refresh instead!
     */
    public function clear()
    {
        trigger_error('Use Session::refresh instead, this method is extremely unsafe', E_USER_DEPRECATED);
        $this->objectManager->clear();
    }

    /**
     * {@inheritDoc}
     *
     * @api
     */
    public function hasPendingChanges()
    {
        return $this->objectManager->hasPendingChanges();
    }

    /**
     * {@inheritDoc}
     *
     * @api
     */
    public function hasPermission($absPath, $actions)
    {
        $actualPermissions = $this->objectManager->getPermissions($absPath);
        $requestedPermissions = explode(',', $actions);

        foreach ($requestedPermissions as $perm) {
            if (! in_array(strtolower(trim($perm)), $actualPermissions)) {
                return false;
            }
        }

        return true;
    }

    /**
     * {@inheritDoc}
     *
     * @api
     */
    public function checkPermission($absPath, $actions)
    {
        if (! $this->hasPermission($absPath, $actions)) {
            throw new AccessControlException($absPath);
        }
    }

    /**
     * {@inheritDoc}
     *
     * Jackalope does currently not check anything and always return true.
     *
     * @api
     */
    public function hasCapability($methodName, $target, array $arguments)
    {
        //we never determine whether operation can be performed as it is optional ;-)
        //TODO: could implement some
        return true;
    }

    /**
     * {@inheritDoc}
     *
     * @api
     */
    public function importXML($parentAbsPath, $uri, $uuidBehavior)
    {
        ImportExport::importXML(
            $this->getNode($parentAbsPath),
            $this->workspace->getNamespaceRegistry(),
            $uri,
            $uuidBehavior
        );
    }

    /**
     * {@inheritDoc}
     *
     * @api
     */
    public function exportSystemView($absPath, $stream, $skipBinary, $noRecurse)
    {
        ImportExport::exportSystemView(
            $this->getNode($absPath),
            $this->workspace->getNamespaceRegistry(),
            $stream,
            $skipBinary,
            $noRecurse
        );
    }


    /**
     * {@inheritDoc}
     *
     * @api
     */
    public function exportDocumentView($absPath, $stream, $skipBinary, $noRecurse)
    {
        ImportExport::exportDocumentView(
            $this->getNode($absPath),
            $this->workspace->getNamespaceRegistry(),
            $stream,
            $skipBinary,
            $noRecurse
        );
    }



    /**
     * {@inheritDoc}
     *
     * @api
     */
    public function setNamespacePrefix($prefix, $uri)
    {
        $this->namespaceRegistry->checkPrefix($prefix);
        throw new NotImplementedException('TODO: implement session scope remapping of namespaces');
        //this will lead to rewrite all names and paths in requests and replies. part of this can be done in ObjectManager::normalizePath
    }

    /**
     * {@inheritDoc}
     *
     * @api
     */
    public function getNamespacePrefixes()
    {
        //TODO: once setNamespacePrefix is implemented, must take session remaps into account
        return $this->namespaceRegistry->getPrefixes();
    }

    /**
     * {@inheritDoc}
     *
     * @api
     */
    public function getNamespaceURI($prefix)
    {
        //TODO: once setNamespacePrefix is implemented, must take session remaps into account
        return $this->namespaceRegistry->getURI($prefix);
    }

    /**
     * {@inheritDoc}
     *
     * @api
     */
    public function getNamespacePrefix($uri)
    {
        //TODO: once setNamespacePrefix is implemented, must take session remaps into account
        return $this->namespaceRegistry->getPrefix($uri);
    }

    /**
     * {@inheritDoc}
     *
     * @api
     */
    public function logout()
    {
        //OPTIMIZATION: flush object manager to help garbage collector
        $this->logout = true;

        if ($this->getRepository()->getDescriptor(RepositoryInterface::OPTION_LOCKING_SUPPORTED)) {
            $this->getWorkspace()->getLockManager()->logout();
        }

        self::unregisterSession($this);
        $this->getTransport()->logout();
    }

    /**
     * {@inheritDoc}
     *
     * @api
     */
    public function isLive()
    {
        return ! $this->logout;
    }

    /**
     * {@inheritDoc}
     *
     * @api
     */
    public function getAccessControlManager()
    {
        throw new UnsupportedRepositoryOperationException();
    }

    /**
     * {@inheritDoc}
     *
     * @api
     */
    public function getRetentionManager()
    {
        throw new UnsupportedRepositoryOperationException();
    }

    /**
     * Implementation specific: The object manager is also used by other components, i.e. the QueryManager.
     *
     * @return ObjectManager the object manager associated with this session
     *
     * @private
     */
    public function getObjectManager()
    {
        return $this->objectManager;
    }

    /**
     * Implementation specific: The transport implementation is also used by other components,
     * i.e. the NamespaceRegistry
     *
     * @return TransportInterface the transport implementation associated with
     *      this session.
     *
     * @private
     */
    public function getTransport()
    {
        return $this->objectManager->getTransport();
    }

    /**
     * Implementation specific: register session in session registry for the stream wrapper.
     *
     * @param Session $session the session to register
     *
     * @private
     */
    protected static function registerSession(Session $session)
    {
        $key = $session->getRegistryKey();
        self::$sessionRegistry[$key] = $session;
    }

    /**
     * Implementation specific: unregister session in session registry on logout.
     *
     * @param Session $session the session to unregister
     *
     * @private
     */
    protected static function unregisterSession(Session $session)
    {
        $key = $session->getRegistryKey();
        unset(self::$sessionRegistry[$key]);
    }

    /**
     * Implementation specific: create an id for the session registry so that the stream wrapper can identify it.
     *
     * @private
     *
     * @return string an id for this session
     */
    public function getRegistryKey()
    {
        return spl_object_hash($this);
    }

    /**
     * Implementation specific: get a session from the session registry for the stream wrapper.
     *
     * @param string $key key for the session
     *
     * @return Session|null the session or null if none is registered with the given key
     *
     * @private
     */
    public static function getSessionFromRegistry($key)
    {
        if (isset(self::$sessionRegistry[$key])) {
            return self::$sessionRegistry[$key];
        }

        return null;
    }

    /**
     * Sets a session specific option.
     *
     * @param string $key   the key to be set
     * @param mixed  $value the value to be set
     *
     * @throws InvalidArgumentException if the option is unknown
     * @throws RepositoryException      if this option is not supported and is
     *      a behaviour relevant option
     *
     * @see BaseTransport::setFetchDepth($value);
     */

    public function setSessionOption($key, $value)
    {
        switch ($key) {
            case self::OPTION_FETCH_DEPTH:
                $this->getTransport()->setFetchDepth($value);
                break;
            case self::OPTION_AUTO_LASTMODIFIED:
                $this->getTransport()->setAutoLastModified($value);
                break;
            default:
                throw new InvalidArgumentException("Unknown option: $key");
        }
    }

    /**
     * Gets a session specific option.
     *
     * @param string $key the key to be gotten
     *
     * @return bool
     *
     * @throws InvalidArgumentException if the option is unknown
     *
     * @see setSessionOption($key, $value);
     */
    public function getSessionOption($key)
    {
        switch ($key) {
            case self::OPTION_FETCH_DEPTH:
                return $this->getTransport()->getFetchDepth();
            case self::OPTION_AUTO_LASTMODIFIED:
                return $this->getTransport()->getAutoLastModified();
        }

        throw new InvalidArgumentException("Unknown option: $key");
    }
}
