<?php

declare(strict_types=1);

namespace Sabre\DAV;

use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Sabre\Event\EmitterInterface;
use Sabre\Event\WildcardEmitterTrait;
use Sabre\HTTP;
use Sabre\HTTP\RequestInterface;
use Sabre\HTTP\ResponseInterface;
use Sabre\Uri;
use Sabre\Xml\Writer;

/**
 * Main DAV server class.
 *
 * @copyright Copyright (C) fruux GmbH (https://fruux.com/)
 * @author Evert Pot (http://evertpot.com/)
 * @license http://sabre.io/license/ Modified BSD License
 */
class Server implements LoggerAwareInterface, EmitterInterface
{
    use LoggerAwareTrait;
    use WildcardEmitterTrait;

    /**
     * Infinity is used for some request supporting the HTTP Depth header and indicates that the operation should traverse the entire tree.
     */
    const DEPTH_INFINITY = -1;

    /**
     * XML namespace for all SabreDAV related elements.
     */
    const NS_SABREDAV = 'http://sabredav.org/ns';

    /**
     * The tree object.
     *
     * @var Tree
     */
    public $tree;

    /**
     * The base uri.
     *
     * @var string
     */
    protected $baseUri = null;

    /**
     * httpResponse.
     *
     * @var HTTP\Response
     */
    public $httpResponse;

    /**
     * httpRequest.
     *
     * @var HTTP\Request
     */
    public $httpRequest;

    /**
     * PHP HTTP Sapi.
     *
     * @var HTTP\Sapi
     */
    public $sapi;

    /**
     * The list of plugins.
     *
     * @var array
     */
    protected $plugins = [];

    /**
     * This property will be filled with a unique string that describes the
     * transaction. This is useful for performance measuring and logging
     * purposes.
     *
     * By default it will just fill it with a lowercased HTTP method name, but
     * plugins override this. For example, the WebDAV-Sync sync-collection
     * report will set this to 'report-sync-collection'.
     *
     * @var string
     */
    public $transactionType;

    /**
     * This is a list of properties that are always server-controlled, and
     * must not get modified with PROPPATCH.
     *
     * Plugins may add to this list.
     *
     * @var string[]
     */
    public $protectedProperties = [
        // RFC4918
        '{DAV:}getcontentlength',
        '{DAV:}getetag',
        '{DAV:}getlastmodified',
        '{DAV:}lockdiscovery',
        '{DAV:}supportedlock',

        // RFC4331
        '{DAV:}quota-available-bytes',
        '{DAV:}quota-used-bytes',

        // RFC3744
        '{DAV:}supported-privilege-set',
        '{DAV:}current-user-privilege-set',
        '{DAV:}acl',
        '{DAV:}acl-restrictions',
        '{DAV:}inherited-acl-set',

        // RFC3253
        '{DAV:}supported-method-set',
        '{DAV:}supported-report-set',

        // RFC6578
        '{DAV:}sync-token',

        // calendarserver.org extensions
        '{http://calendarserver.org/ns/}ctag',

        // sabredav extensions
        '{http://sabredav.org/ns}sync-token',
    ];

    /**
     * This is a flag that allow or not showing file, line and code
     * of the exception in the returned XML.
     *
     * @var bool
     */
    public $debugExceptions = false;

    /**
     * This property allows you to automatically add the 'resourcetype' value
     * based on a node's classname or interface.
     *
     * The preset ensures that {DAV:}collection is automatically added for nodes
     * implementing Sabre\DAV\ICollection.
     *
     * @var array
     */
    public $resourceTypeMapping = [
        'Sabre\\DAV\\ICollection' => '{DAV:}collection',
    ];

    /**
     * This property allows the usage of Depth: infinity on PROPFIND requests.
     *
     * By default Depth: infinity is treated as Depth: 1. Allowing Depth:
     * infinity is potentially risky, as it allows a single client to do a full
     * index of the webdav server, which is an easy DoS attack vector.
     *
     * Only turn this on if you know what you're doing.
     *
     * @var bool
     */
    public $enablePropfindDepthInfinity = false;

    /**
     * Reference to the XML utility object.
     *
     * @var Xml\Service
     */
    public $xml;

    /**
     * If this setting is turned off, SabreDAV's version number will be hidden
     * from various places.
     *
     * Some people feel this is a good security measure.
     *
     * @var bool
     */
    public static $exposeVersion = true;

    /**
     * If this setting is turned on, any multi status response on any PROPFIND will be streamed to the output buffer.
     * This will be beneficial for large result sets which will no longer consume a large amount of memory as well as
     * send back data to the client earlier.
     *
     * @var bool
     */
    public static $streamMultiStatus = false;

    /**
     * Sets up the server.
     *
     * If a Sabre\DAV\Tree object is passed as an argument, it will
     * use it as the directory tree. If a Sabre\DAV\INode is passed, it
     * will create a Sabre\DAV\Tree and use the node as the root.
     *
     * If nothing is passed, a Sabre\DAV\SimpleCollection is created in
     * a Sabre\DAV\Tree.
     *
     * If an array is passed, we automatically create a root node, and use
     * the nodes in the array as top-level children.
     *
     * @param Tree|INode|array|null $treeOrNode The tree object
     *
     * @throws Exception
     */
    public function __construct($treeOrNode = null, HTTP\Sapi $sapi = null)
    {
        if ($treeOrNode instanceof Tree) {
            $this->tree = $treeOrNode;
        } elseif ($treeOrNode instanceof INode) {
            $this->tree = new Tree($treeOrNode);
        } elseif (is_array($treeOrNode)) {
            $root = new SimpleCollection('root', $treeOrNode);
            $this->tree = new Tree($root);
        } elseif (is_null($treeOrNode)) {
            $root = new SimpleCollection('root');
            $this->tree = new Tree($root);
        } else {
            throw new Exception('Invalid argument passed to constructor. Argument must either be an instance of Sabre\\DAV\\Tree, Sabre\\DAV\\INode, an array or null');
        }

        $this->xml = new Xml\Service();
        $this->sapi = $sapi ?? new HTTP\Sapi();
        $this->httpResponse = new HTTP\Response();
        $this->httpRequest = $this->sapi->getRequest();
        $this->addPlugin(new CorePlugin());
    }

    /**
     * Starts the DAV Server.
     */
    public function start()
    {
        try {
            // If nginx (pre-1.2) is used as a proxy server, and SabreDAV as an
            // origin, we must make sure we send back HTTP/1.0 if this was
            // requested.
            // This is mainly because nginx doesn't support Chunked Transfer
            // Encoding, and this forces the webserver SabreDAV is running on,
            // to buffer entire responses to calculate Content-Length.
            $this->httpResponse->setHTTPVersion($this->httpRequest->getHTTPVersion());

            // Setting the base url
            $this->httpRequest->setBaseUrl($this->getBaseUri());
            $this->invokeMethod($this->httpRequest, $this->httpResponse);
        } catch (\Throwable $e) {
            try {
                $this->emit('exception', [$e]);
            } catch (\Exception $ignore) {
            }
            $DOM = new \DOMDocument('1.0', 'utf-8');
            $DOM->formatOutput = true;

            $error = $DOM->createElementNS('DAV:', 'd:error');
            $error->setAttribute('xmlns:s', self::NS_SABREDAV);
            $DOM->appendChild($error);

            $h = function ($v) {
                return htmlspecialchars((string) $v, ENT_NOQUOTES, 'UTF-8');
            };

            if (self::$exposeVersion) {
                $error->appendChild($DOM->createElement('s:sabredav-version', $h(Version::VERSION)));
            }

            $error->appendChild($DOM->createElement('s:exception', $h(get_class($e))));
            $error->appendChild($DOM->createElement('s:message', $h($e->getMessage())));
            if ($this->debugExceptions) {
                $error->appendChild($DOM->createElement('s:file', $h($e->getFile())));
                $error->appendChild($DOM->createElement('s:line', $h($e->getLine())));
                $error->appendChild($DOM->createElement('s:code', $h($e->getCode())));
                $error->appendChild($DOM->createElement('s:stacktrace', $h($e->getTraceAsString())));
            }

            if ($this->debugExceptions) {
                $previous = $e;
                while ($previous = $previous->getPrevious()) {
                    $xPrevious = $DOM->createElement('s:previous-exception');
                    $xPrevious->appendChild($DOM->createElement('s:exception', $h(get_class($previous))));
                    $xPrevious->appendChild($DOM->createElement('s:message', $h($previous->getMessage())));
                    $xPrevious->appendChild($DOM->createElement('s:file', $h($previous->getFile())));
                    $xPrevious->appendChild($DOM->createElement('s:line', $h($previous->getLine())));
                    $xPrevious->appendChild($DOM->createElement('s:code', $h($previous->getCode())));
                    $xPrevious->appendChild($DOM->createElement('s:stacktrace', $h($previous->getTraceAsString())));
                    $error->appendChild($xPrevious);
                }
            }

            if ($e instanceof Exception) {
                $httpCode = $e->getHTTPCode();
                $e->serialize($this, $error);
                $headers = $e->getHTTPHeaders($this);
            } else {
                $httpCode = 500;
                $headers = [];
            }
            $headers['Content-Type'] = 'application/xml; charset=utf-8';

            $this->httpResponse->setStatus($httpCode);
            $this->httpResponse->setHeaders($headers);
            $this->httpResponse->setBody($DOM->saveXML());
            $this->sapi->sendResponse($this->httpResponse);
        }
    }

    /**
     * Alias of start().
     *
     * @deprecated
     */
    public function exec()
    {
        $this->start();
    }

    /**
     * Sets the base server uri.
     *
     * @param string $uri
     */
    public function setBaseUri($uri)
    {
        // If the baseUri does not end with a slash, we must add it
        if ('/' !== $uri[strlen($uri) - 1]) {
            $uri .= '/';
        }

        $this->baseUri = $uri;
    }

    /**
     * Returns the base responding uri.
     *
     * @return string
     */
    public function getBaseUri()
    {
        if (is_null($this->baseUri)) {
            $this->baseUri = $this->guessBaseUri();
        }

        return $this->baseUri;
    }

    /**
     * This method attempts to detect the base uri.
     * Only the PATH_INFO variable is considered.
     *
     * If this variable is not set, the root (/) is assumed.
     *
     * @return string
     */
    public function guessBaseUri()
    {
        $pathInfo = $this->httpRequest->getRawServerValue('PATH_INFO');
        $uri = $this->httpRequest->getRawServerValue('REQUEST_URI');

        // If PATH_INFO is found, we can assume it's accurate.
        if (!empty($pathInfo)) {
            // We need to make sure we ignore the QUERY_STRING part
            if ($pos = strpos($uri, '?')) {
                $uri = substr($uri, 0, $pos);
            }

            // PATH_INFO is only set for urls, such as: /example.php/path
            // in that case PATH_INFO contains '/path'.
            // Note that REQUEST_URI is percent encoded, while PATH_INFO is
            // not, Therefore they are only comparable if we first decode
            // REQUEST_INFO as well.
            $decodedUri = HTTP\decodePath($uri);

            // A simple sanity check:
            if (substr($decodedUri, strlen($decodedUri) - strlen($pathInfo)) === $pathInfo) {
                $baseUri = substr($decodedUri, 0, strlen($decodedUri) - strlen($pathInfo));

                return rtrim($baseUri, '/').'/';
            }

            throw new Exception('The REQUEST_URI ('.$uri.') did not end with the contents of PATH_INFO ('.$pathInfo.'). This server might be misconfigured.');
        }

        // The last fallback is that we're just going to assume the server root.
        return '/';
    }

    /**
     * Adds a plugin to the server.
     *
     * For more information, console the documentation of Sabre\DAV\ServerPlugin
     */
    public function addPlugin(ServerPlugin $plugin)
    {
        $this->plugins[$plugin->getPluginName()] = $plugin;
        $plugin->initialize($this);
    }

    /**
     * Returns an initialized plugin by it's name.
     *
     * This function returns null if the plugin was not found.
     *
     * @param string $name
     *
     * @return ServerPlugin
     */
    public function getPlugin($name)
    {
        if (isset($this->plugins[$name])) {
            return $this->plugins[$name];
        }

        return null;
    }

    /**
     * Returns all plugins.
     *
     * @return array
     */
    public function getPlugins()
    {
        return $this->plugins;
    }

    /**
     * Returns the PSR-3 logger object.
     *
     * @return LoggerInterface
     */
    public function getLogger()
    {
        if (!$this->logger) {
            $this->logger = new NullLogger();
        }

        return $this->logger;
    }

    /**
     * Handles a http request, and execute a method based on its name.
     *
     * @param bool $sendResponse whether to send the HTTP response to the DAV client
     */
    public function invokeMethod(RequestInterface $request, ResponseInterface $response, $sendResponse = true)
    {
        $method = $request->getMethod();

        if (!$this->emit('beforeMethod:'.$method, [$request, $response])) {
            return;
        }

        if (self::$exposeVersion) {
            $response->setHeader('X-Sabre-Version', Version::VERSION);
        }

        $this->transactionType = strtolower($method);

        if (!$this->checkPreconditions($request, $response)) {
            $this->sapi->sendResponse($response);

            return;
        }

        if ($this->emit('method:'.$method, [$request, $response])) {
            $exMessage = 'There was no plugin in the system that was willing to handle this '.$method.' method.';
            if ('GET' === $method) {
                $exMessage .= ' Enable the Browser plugin to get a better result here.';
            }

            // Unsupported method
            throw new Exception\NotImplemented($exMessage);
        }

        if (!$this->emit('afterMethod:'.$method, [$request, $response])) {
            return;
        }

        if (null === $response->getStatus()) {
            throw new Exception('No subsystem set a valid HTTP status code. Something must have interrupted the request without providing further detail.');
        }
        if ($sendResponse) {
            $this->sapi->sendResponse($response);
            $this->emit('afterResponse', [$request, $response]);
        }
    }

    // {{{ HTTP/WebDAV protocol helpers

    /**
     * Returns an array with all the supported HTTP methods for a specific uri.
     *
     * @param string $path
     *
     * @return array
     */
    public function getAllowedMethods($path)
    {
        $methods = [
            'OPTIONS',
            'GET',
            'HEAD',
            'DELETE',
            'PROPFIND',
            'PUT',
            'PROPPATCH',
            'COPY',
            'MOVE',
            'REPORT',
        ];

        // The MKCOL is only allowed on an unmapped uri
        try {
            $this->tree->getNodeForPath($path);
        } catch (Exception\NotFound $e) {
            $methods[] = 'MKCOL';
        }

        // We're also checking if any of the plugins register any new methods
        foreach ($this->plugins as $plugin) {
            $methods = array_merge($methods, $plugin->getHTTPMethods($path));
        }
        array_unique($methods);

        return $methods;
    }

    /**
     * Gets the uri for the request, keeping the base uri into consideration.
     *
     * @return string
     */
    public function getRequestUri()
    {
        return $this->calculateUri($this->httpRequest->getUrl());
    }

    /**
     * Turns a URI such as the REQUEST_URI into a local path.
     *
     * This method:
     *   * strips off the base path
     *   * normalizes the path
     *   * uri-decodes the path
     *
     * @param string $uri
     *
     * @throws Exception\Forbidden A permission denied exception is thrown whenever there was an attempt to supply a uri outside of the base uri
     *
     * @return string
     */
    public function calculateUri($uri)
    {
        if ('' != $uri && '/' != $uri[0] && strpos($uri, '://')) {
            $uri = parse_url($uri, PHP_URL_PATH);
        }

        $uri = Uri\normalize(preg_replace('|/+|', '/', $uri));
        $baseUri = Uri\normalize($this->getBaseUri());

        if (0 === strpos($uri, $baseUri)) {
            return trim(HTTP\decodePath(substr($uri, strlen($baseUri))), '/');

        // A special case, if the baseUri was accessed without a trailing
        // slash, we'll accept it as well.
        } elseif ($uri.'/' === $baseUri) {
            return '';
        } else {
            throw new Exception\Forbidden('Requested uri ('.$uri.') is out of base uri ('.$this->getBaseUri().')');
        }
    }

    /**
     * Returns the HTTP depth header.
     *
     * This method returns the contents of the HTTP depth request header. If the depth header was 'infinity' it will return the Sabre\DAV\Server::DEPTH_INFINITY object
     * It is possible to supply a default depth value, which is used when the depth header has invalid content, or is completely non-existent
     *
     * @param mixed $default
     *
     * @return int
     */
    public function getHTTPDepth($default = self::DEPTH_INFINITY)
    {
        // If its not set, we'll grab the default
        $depth = $this->httpRequest->getHeader('Depth');

        if (is_null($depth)) {
            return $default;
        }

        if ('infinity' == $depth) {
            return self::DEPTH_INFINITY;
        }

        // If its an unknown value. we'll grab the default
        if (!ctype_digit($depth)) {
            return $default;
        }

        return (int) $depth;
    }

    /**
     * Returns the HTTP range header.
     *
     * This method returns null if there is no well-formed HTTP range request
     * header or array($start, $end).
     *
     * The first number is the offset of the first byte in the range.
     * The second number is the offset of the last byte in the range.
     *
     * If the second offset is null, it should be treated as the offset of the last byte of the entity
     * If the first offset is null, the second offset should be used to retrieve the last x bytes of the entity
     *
     * @return int[]|null
     */
    public function getHTTPRange()
    {
        $range = $this->httpRequest->getHeader('range');
        if (is_null($range)) {
            return null;
        }

        // Matching "Range: bytes=1234-5678: both numbers are optional

        if (!preg_match('/^bytes=([0-9]*)-([0-9]*)$/i', $range, $matches)) {
            return null;
        }

        if ('' === $matches[1] && '' === $matches[2]) {
            return null;
        }

        return [
            '' !== $matches[1] ? (int) $matches[1] : null,
            '' !== $matches[2] ? (int) $matches[2] : null,
        ];
    }

    /**
     * Returns the HTTP Prefer header information.
     *
     * The prefer header is defined in:
     * http://tools.ietf.org/html/draft-snell-http-prefer-14
     *
     * This method will return an array with options.
     *
     * Currently, the following options may be returned:
     *  [
     *      'return-asynch'         => true,
     *      'return-minimal'        => true,
     *      'return-representation' => true,
     *      'wait'                  => 30,
     *      'strict'                => true,
     *      'lenient'               => true,
     *  ]
     *
     * This method also supports the Brief header, and will also return
     * 'return-minimal' if the brief header was set to 't'.
     *
     * For the boolean options, false will be returned if the headers are not
     * specified. For the integer options it will be 'null'.
     *
     * @return array
     */
    public function getHTTPPrefer()
    {
        $result = [
            // can be true or false
            'respond-async' => false,
            // Could be set to 'representation' or 'minimal'.
            'return' => null,
            // Used as a timeout, is usually a number.
            'wait' => null,
            // can be 'strict' or 'lenient'.
            'handling' => false,
        ];

        if ($prefer = $this->httpRequest->getHeader('Prefer')) {
            $result = array_merge(
                $result,
                HTTP\parsePrefer($prefer)
            );
        } elseif ('t' == $this->httpRequest->getHeader('Brief')) {
            $result['return'] = 'minimal';
        }

        return $result;
    }

    /**
     * Returns information about Copy and Move requests.
     *
     * This function is created to help getting information about the source and the destination for the
     * WebDAV MOVE and COPY HTTP request. It also validates a lot of information and throws proper exceptions
     *
     * The returned value is an array with the following keys:
     *   * destination - Destination path
     *   * destinationExists - Whether or not the destination is an existing url (and should therefore be overwritten)
     *
     * @throws Exception\BadRequest           upon missing or broken request headers
     * @throws Exception\UnsupportedMediaType when trying to copy into a
     *                                        non-collection
     * @throws Exception\PreconditionFailed   if overwrite is set to false, but
     *                                        the destination exists
     * @throws Exception\Forbidden            when source and destination paths are
     *                                        identical
     * @throws Exception\Conflict             when trying to copy a node into its own
     *                                        subtree
     *
     * @return array
     */
    public function getCopyAndMoveInfo(RequestInterface $request)
    {
        // Collecting the relevant HTTP headers
        if (!$request->getHeader('Destination')) {
            throw new Exception\BadRequest('The destination header was not supplied');
        }
        $destination = $this->calculateUri($request->getHeader('Destination'));
        $overwrite = $request->getHeader('Overwrite');
        if (!$overwrite) {
            $overwrite = 'T';
        }
        if ('T' == strtoupper($overwrite)) {
            $overwrite = true;
        } elseif ('F' == strtoupper($overwrite)) {
            $overwrite = false;
        }
        // We need to throw a bad request exception, if the header was invalid
        else {
            throw new Exception\BadRequest('The HTTP Overwrite header should be either T or F');
        }
        list($destinationDir) = Uri\split($destination);

        try {
            $destinationParent = $this->tree->getNodeForPath($destinationDir);
            if (!($destinationParent instanceof ICollection)) {
                throw new Exception\UnsupportedMediaType('The destination node is not a collection');
            }
        } catch (Exception\NotFound $e) {
            // If the destination parent node is not found, we throw a 409
            throw new Exception\Conflict('The destination node is not found');
        }

        try {
            $destinationNode = $this->tree->getNodeForPath($destination);

            // If this succeeded, it means the destination already exists
            // we'll need to throw precondition failed in case overwrite is false
            if (!$overwrite) {
                throw new Exception\PreconditionFailed('The destination node already exists, and the overwrite header is set to false', 'Overwrite');
            }
        } catch (Exception\NotFound $e) {
            // Destination didn't exist, we're all good
            $destinationNode = false;
        }

        $requestPath = $request->getPath();
        if ($destination === $requestPath) {
            throw new Exception\Forbidden('Source and destination uri are identical.');
        }
        if (substr($destination, 0, strlen($requestPath) + 1) === $requestPath.'/') {
            throw new Exception\Conflict('The destination may not be part of the same subtree as the source path.');
        }

        // These are the three relevant properties we need to return
        return [
            'destination' => $destination,
            'destinationExists' => (bool) $destinationNode,
            'destinationNode' => $destinationNode,
        ];
    }

    /**
     * Returns a list of properties for a path.
     *
     * This is a simplified version getPropertiesForPath. If you aren't
     * interested in status codes, but you just want to have a flat list of
     * properties, use this method.
     *
     * Please note though that any problems related to retrieving properties,
     * such as permission issues will just result in an empty array being
     * returned.
     *
     * @param string $path
     * @param array  $propertyNames
     *
     * @return array
     */
    public function getProperties($path, $propertyNames)
    {
        $result = $this->getPropertiesForPath($path, $propertyNames, 0);
        if (isset($result[0][200])) {
            return $result[0][200];
        } else {
            return [];
        }
    }

    /**
     * A kid-friendly way to fetch properties for a node's children.
     *
     * The returned array will be indexed by the path of the of child node.
     * Only properties that are actually found will be returned.
     *
     * The parent node will not be returned.
     *
     * @param string $path
     * @param array  $propertyNames
     *
     * @return array
     */
    public function getPropertiesForChildren($path, $propertyNames)
    {
        $result = [];
        foreach ($this->getPropertiesForPath($path, $propertyNames, 1) as $k => $row) {
            // Skipping the parent path
            if (0 === $k) {
                continue;
            }

            $result[$row['href']] = $row[200];
        }

        return $result;
    }

    /**
     * Returns a list of HTTP headers for a particular resource.
     *
     * The generated http headers are based on properties provided by the
     * resource. The method basically provides a simple mapping between
     * DAV property and HTTP header.
     *
     * The headers are intended to be used for HEAD and GET requests.
     *
     * @param string $path
     *
     * @return array
     */
    public function getHTTPHeaders($path)
    {
        $propertyMap = [
            '{DAV:}getcontenttype' => 'Content-Type',
            '{DAV:}getcontentlength' => 'Content-Length',
            '{DAV:}getlastmodified' => 'Last-Modified',
            '{DAV:}getetag' => 'ETag',
        ];

        $properties = $this->getProperties($path, array_keys($propertyMap));

        $headers = [];
        foreach ($propertyMap as $property => $header) {
            if (!isset($properties[$property])) {
                continue;
            }

            if (is_scalar($properties[$property])) {
                $headers[$header] = $properties[$property];

            // GetLastModified gets special cased
            } elseif ($properties[$property] instanceof Xml\Property\GetLastModified) {
                $headers[$header] = HTTP\toDate($properties[$property]->getTime());
            }
        }

        return $headers;
    }

    /**
     * Small helper to support PROPFIND with DEPTH_INFINITY.
     *
     * @param array $yieldFirst
     *
     * @return \Traversable
     */
    private function generatePathNodes(PropFind $propFind, array $yieldFirst = null)
    {
        if (null !== $yieldFirst) {
            yield $yieldFirst;
        }
        $newDepth = $propFind->getDepth();
        $path = $propFind->getPath();

        if (self::DEPTH_INFINITY !== $newDepth) {
            --$newDepth;
        }

        $propertyNames = $propFind->getRequestedProperties();
        $propFindType = !$propFind->isAllProps() ? PropFind::NORMAL : PropFind::ALLPROPS;

        foreach ($this->tree->getChildren($path) as $childNode) {
            if ('' !== $path) {
                $subPath = $path.'/'.$childNode->getName();
            } else {
                $subPath = $childNode->getName();
            }
            $subPropFind = new PropFind($subPath, $propertyNames, $newDepth, $propFindType);

            yield [
                $subPropFind,
                $childNode,
            ];

            if ((self::DEPTH_INFINITY === $newDepth || $newDepth >= 1) && $childNode instanceof ICollection) {
                foreach ($this->generatePathNodes($subPropFind) as $subItem) {
                    yield $subItem;
                }
            }
        }
    }

    /**
     * Returns a list of properties for a given path.
     *
     * The path that should be supplied should have the baseUrl stripped out
     * The list of properties should be supplied in Clark notation. If the list is empty
     * 'allprops' is assumed.
     *
     * If a depth of 1 is requested child elements will also be returned.
     *
     * @param string $path
     * @param array  $propertyNames
     * @param int    $depth
     *
     * @return array
     *
     * @deprecated Use getPropertiesIteratorForPath() instead (as it's more memory efficient)
     * @see getPropertiesIteratorForPath()
     */
    public function getPropertiesForPath($path, $propertyNames = [], $depth = 0)
    {
        return iterator_to_array($this->getPropertiesIteratorForPath($path, $propertyNames, $depth));
    }

    /**
     * Returns a list of properties for a given path.
     *
     * The path that should be supplied should have the baseUrl stripped out
     * The list of properties should be supplied in Clark notation. If the list is empty
     * 'allprops' is assumed.
     *
     * If a depth of 1 is requested child elements will also be returned.
     *
     * @param string $path
     * @param array  $propertyNames
     * @param int    $depth
     *
     * @return \Iterator
     */
    public function getPropertiesIteratorForPath($path, $propertyNames = [], $depth = 0)
    {
        // The only two options for the depth of a propfind is 0 or 1 - as long as depth infinity is not enabled
        if (!$this->enablePropfindDepthInfinity && 0 != $depth) {
            $depth = 1;
        }

        $path = trim($path, '/');

        $propFindType = $propertyNames ? PropFind::NORMAL : PropFind::ALLPROPS;
        $propFind = new PropFind($path, (array) $propertyNames, $depth, $propFindType);

        $parentNode = $this->tree->getNodeForPath($path);

        $propFindRequests = [[
            $propFind,
            $parentNode,
        ]];

        if (($depth > 0 || self::DEPTH_INFINITY === $depth) && $parentNode instanceof ICollection) {
            $propFindRequests = $this->generatePathNodes(clone $propFind, current($propFindRequests));
        }

        foreach ($propFindRequests as $propFindRequest) {
            list($propFind, $node) = $propFindRequest;
            $r = $this->getPropertiesByNode($propFind, $node);
            if ($r) {
                $result = $propFind->getResultForMultiStatus();
                $result['href'] = $propFind->getPath();

                // WebDAV recommends adding a slash to the path, if the path is
                // a collection.
                // Furthermore, iCal also demands this to be the case for
                // principals. This is non-standard, but we support it.
                $resourceType = $this->getResourceTypeForNode($node);
                if (in_array('{DAV:}collection', $resourceType) || in_array('{DAV:}principal', $resourceType)) {
                    $result['href'] .= '/';
                }
                yield $result;
            }
        }
    }

    /**
     * Returns a list of properties for a list of paths.
     *
     * The path that should be supplied should have the baseUrl stripped out
     * The list of properties should be supplied in Clark notation. If the list is empty
     * 'allprops' is assumed.
     *
     * The result is returned as an array, with paths for it's keys.
     * The result may be returned out of order.
     *
     * @return array
     */
    public function getPropertiesForMultiplePaths(array $paths, array $propertyNames = [])
    {
        $result = [
        ];

        $nodes = $this->tree->getMultipleNodes($paths);

        foreach ($nodes as $path => $node) {
            $propFind = new PropFind($path, $propertyNames);
            $r = $this->getPropertiesByNode($propFind, $node);
            if ($r) {
                $result[$path] = $propFind->getResultForMultiStatus();
                $result[$path]['href'] = $path;

                $resourceType = $this->getResourceTypeForNode($node);
                if (in_array('{DAV:}collection', $resourceType) || in_array('{DAV:}principal', $resourceType)) {
                    $result[$path]['href'] .= '/';
                }
            }
        }

        return $result;
    }

    /**
     * Determines all properties for a node.
     *
     * This method tries to grab all properties for a node. This method is used
     * internally getPropertiesForPath and a few others.
     *
     * It could be useful to call this, if you already have an instance of your
     * target node and simply want to run through the system to get a correct
     * list of properties.
     *
     * @return bool
     */
    public function getPropertiesByNode(PropFind $propFind, INode $node)
    {
        return $this->emit('propFind', [$propFind, $node]);
    }

    /**
     * This method is invoked by sub-systems creating a new file.
     *
     * Currently this is done by HTTP PUT and HTTP LOCK (in the Locks_Plugin).
     * It was important to get this done through a centralized function,
     * allowing plugins to intercept this using the beforeCreateFile event.
     *
     * This method will return true if the file was actually created
     *
     * @param string   $uri
     * @param resource $data
     * @param string   $etag
     *
     * @return bool
     */
    public function createFile($uri, $data, &$etag = null)
    {
        list($dir, $name) = Uri\split($uri);

        if (!$this->emit('beforeBind', [$uri])) {
            return false;
        }

        try {
            $parent = $this->tree->getNodeForPath($dir);
        } catch (Exception\NotFound $e) {
            throw new Exception\Conflict('Files cannot be created in non-existent collections');
        }

        if (!$parent instanceof ICollection) {
            throw new Exception\Conflict('Files can only be created as children of collections');
        }

        // It is possible for an event handler to modify the content of the
        // body, before it gets written. If this is the case, $modified
        // should be set to true.
        //
        // If $modified is true, we must not send back an ETag.
        $modified = false;
        if (!$this->emit('beforeCreateFile', [$uri, &$data, $parent, &$modified])) {
            return false;
        }

        $etag = $parent->createFile($name, $data);

        if ($modified) {
            $etag = null;
        }

        $this->tree->markDirty($dir.'/'.$name);

        $this->emit('afterBind', [$uri]);
        $this->emit('afterCreateFile', [$uri, $parent]);

        return true;
    }

    /**
     * This method is invoked by sub-systems updating a file.
     *
     * This method will return true if the file was actually updated
     *
     * @param string   $uri
     * @param resource $data
     * @param string   $etag
     *
     * @return bool
     */
    public function updateFile($uri, $data, &$etag = null)
    {
        $node = $this->tree->getNodeForPath($uri);

        // It is possible for an event handler to modify the content of the
        // body, before it gets written. If this is the case, $modified
        // should be set to true.
        //
        // If $modified is true, we must not send back an ETag.
        $modified = false;
        if (!$this->emit('beforeWriteContent', [$uri, $node, &$data, &$modified])) {
            return false;
        }

        $etag = $node->put($data);
        if ($modified) {
            $etag = null;
        }
        $this->emit('afterWriteContent', [$uri, $node]);

        return true;
    }

    /**
     * This method is invoked by sub-systems creating a new directory.
     *
     * @param string $uri
     */
    public function createDirectory($uri)
    {
        $this->createCollection($uri, new MkCol(['{DAV:}collection'], []));
    }

    /**
     * Use this method to create a new collection.
     *
     * @param string $uri The new uri
     *
     * @return array|null
     */
    public function createCollection($uri, MkCol $mkCol)
    {
        list($parentUri, $newName) = Uri\split($uri);

        // Making sure the parent exists
        try {
            $parent = $this->tree->getNodeForPath($parentUri);
        } catch (Exception\NotFound $e) {
            throw new Exception\Conflict('Parent node does not exist');
        }

        // Making sure the parent is a collection
        if (!$parent instanceof ICollection) {
            throw new Exception\Conflict('Parent node is not a collection');
        }

        // Making sure the child does not already exist
        try {
            $parent->getChild($newName);

            // If we got here.. it means there's already a node on that url, and we need to throw a 405
            throw new Exception\MethodNotAllowed('The resource you tried to create already exists');
        } catch (Exception\NotFound $e) {
            // NotFound is the expected behavior.
        }

        if (!$this->emit('beforeBind', [$uri])) {
            return;
        }

        if ($parent instanceof IExtendedCollection) {
            /*
             * If the parent is an instance of IExtendedCollection, it means that
             * we can pass the MkCol object directly as it may be able to store
             * properties immediately.
             */
            $parent->createExtendedCollection($newName, $mkCol);
        } else {
            /*
             * If the parent is a standard ICollection, it means only
             * 'standard' collections can be created, so we should fail any
             * MKCOL operation that carries extra resourcetypes.
             */
            if (count($mkCol->getResourceType()) > 1) {
                throw new Exception\InvalidResourceType('The {DAV:}resourcetype you specified is not supported here.');
            }

            $parent->createDirectory($newName);
        }

        // If there are any properties that have not been handled/stored,
        // we ask the 'propPatch' event to handle them. This will allow for
        // example the propertyStorage system to store properties upon MKCOL.
        if ($mkCol->getRemainingMutations()) {
            $this->emit('propPatch', [$uri, $mkCol]);
        }
        $success = $mkCol->commit();

        if (!$success) {
            $result = $mkCol->getResult();

            $formattedResult = [
                'href' => $uri,
            ];

            foreach ($result as $propertyName => $status) {
                if (!isset($formattedResult[$status])) {
                    $formattedResult[$status] = [];
                }
                $formattedResult[$status][$propertyName] = null;
            }

            return $formattedResult;
        }

        $this->tree->markDirty($parentUri);
        $this->emit('afterBind', [$uri]);
        $this->emit('afterCreateCollection', [$uri]);
    }

    /**
     * This method updates a resource's properties.
     *
     * The properties array must be a list of properties. Array-keys are
     * property names in clarknotation, array-values are it's values.
     * If a property must be deleted, the value should be null.
     *
     * Note that this request should either completely succeed, or
     * completely fail.
     *
     * The response is an array with properties for keys, and http status codes
     * as their values.
     *
     * @param string $path
     *
     * @return array
     */
    public function updateProperties($path, array $properties)
    {
        $propPatch = new PropPatch($properties);
        $this->emit('propPatch', [$path, $propPatch]);
        $propPatch->commit();

        return $propPatch->getResult();
    }

    /**
     * This method checks the main HTTP preconditions.
     *
     * Currently these are:
     *   * If-Match
     *   * If-None-Match
     *   * If-Modified-Since
     *   * If-Unmodified-Since
     *
     * The method will return true if all preconditions are met
     * The method will return false, or throw an exception if preconditions
     * failed. If false is returned the operation should be aborted, and
     * the appropriate HTTP response headers are already set.
     *
     * Normally this method will throw 412 Precondition Failed for failures
     * related to If-None-Match, If-Match and If-Unmodified Since. It will
     * set the status to 304 Not Modified for If-Modified_since.
     *
     * @return bool
     */
    public function checkPreconditions(RequestInterface $request, ResponseInterface $response)
    {
        $path = $request->getPath();
        $node = null;
        $lastMod = null;
        $etag = null;

        if ($ifMatch = $request->getHeader('If-Match')) {
            // If-Match contains an entity tag. Only if the entity-tag
            // matches we are allowed to make the request succeed.
            // If the entity-tag is '*' we are only allowed to make the
            // request succeed if a resource exists at that url.
            try {
                $node = $this->tree->getNodeForPath($path);
            } catch (Exception\NotFound $e) {
                throw new Exception\PreconditionFailed('An If-Match header was specified and the resource did not exist', 'If-Match');
            }

            // Only need to check entity tags if they are not *
            if ('*' !== $ifMatch) {
                // There can be multiple ETags
                $ifMatch = explode(',', $ifMatch);
                $haveMatch = false;
                foreach ($ifMatch as $ifMatchItem) {
                    // Stripping any extra spaces
                    $ifMatchItem = trim($ifMatchItem, ' ');

                    $etag = $node instanceof IFile ? $node->getETag() : null;
                    if ($etag === $ifMatchItem) {
                        $haveMatch = true;
                    } else {
                        // Evolution has a bug where it sometimes prepends the "
                        // with a \. This is our workaround.
                        if (str_replace('\\"', '"', $ifMatchItem) === $etag) {
                            $haveMatch = true;
                        }
                    }
                }
                if (!$haveMatch) {
                    if ($etag) {
                        $response->setHeader('ETag', $etag);
                    }
                    throw new Exception\PreconditionFailed('An If-Match header was specified, but none of the specified ETags matched.', 'If-Match');
                }
            }
        }

        if ($ifNoneMatch = $request->getHeader('If-None-Match')) {
            // The If-None-Match header contains an ETag.
            // Only if the ETag does not match the current ETag, the request will succeed
            // The header can also contain *, in which case the request
            // will only succeed if the entity does not exist at all.
            $nodeExists = true;
            if (!$node) {
                try {
                    $node = $this->tree->getNodeForPath($path);
                } catch (Exception\NotFound $e) {
                    $nodeExists = false;
                }
            }
            if ($nodeExists) {
                $haveMatch = false;
                if ('*' === $ifNoneMatch) {
                    $haveMatch = true;
                } else {
                    // There might be multiple ETags
                    $ifNoneMatch = explode(',', $ifNoneMatch);
                    $etag = $node instanceof IFile ? $node->getETag() : null;

                    foreach ($ifNoneMatch as $ifNoneMatchItem) {
                        // Stripping any extra spaces
                        $ifNoneMatchItem = trim($ifNoneMatchItem, ' ');

                        if ($etag === $ifNoneMatchItem) {
                            $haveMatch = true;
                        }
                    }
                }

                if ($haveMatch) {
                    if ($etag) {
                        $response->setHeader('ETag', $etag);
                    }
                    if ('GET' === $request->getMethod()) {
                        $response->setStatus(304);

                        return false;
                    } else {
                        throw new Exception\PreconditionFailed('An If-None-Match header was specified, but the ETag matched (or * was specified).', 'If-None-Match');
                    }
                }
            }
        }

        if (!$ifNoneMatch && ($ifModifiedSince = $request->getHeader('If-Modified-Since'))) {
            // The If-Modified-Since header contains a date. We
            // will only return the entity if it has been changed since
            // that date. If it hasn't been changed, we return a 304
            // header
            // Note that this header only has to be checked if there was no If-None-Match header
            // as per the HTTP spec.
            $date = HTTP\parseDate($ifModifiedSince);

            if ($date) {
                if (is_null($node)) {
                    $node = $this->tree->getNodeForPath($path);
                }
                $lastMod = $node->getLastModified();
                if ($lastMod) {
                    $lastMod = new \DateTime('@'.$lastMod);
                    if ($lastMod <= $date) {
                        $response->setStatus(304);
                        $response->setHeader('Last-Modified', HTTP\toDate($lastMod));

                        return false;
                    }
                }
            }
        }

        if ($ifUnmodifiedSince = $request->getHeader('If-Unmodified-Since')) {
            // The If-Unmodified-Since will allow allow the request if the
            // entity has not changed since the specified date.
            $date = HTTP\parseDate($ifUnmodifiedSince);

            // We must only check the date if it's valid
            if ($date) {
                if (is_null($node)) {
                    $node = $this->tree->getNodeForPath($path);
                }
                $lastMod = $node->getLastModified();
                if ($lastMod) {
                    $lastMod = new \DateTime('@'.$lastMod);
                    if ($lastMod > $date) {
                        throw new Exception\PreconditionFailed('An If-Unmodified-Since header was specified, but the entity has been changed since the specified date.', 'If-Unmodified-Since');
                    }
                }
            }
        }

        // Now the hardest, the If: header. The If: header can contain multiple
        // urls, ETags and so-called 'state tokens'.
        //
        // Examples of state tokens include lock-tokens (as defined in rfc4918)
        // and sync-tokens (as defined in rfc6578).
        //
        // The only proper way to deal with these, is to emit events, that a
        // Sync and Lock plugin can pick up.
        $ifConditions = $this->getIfConditions($request);

        foreach ($ifConditions as $kk => $ifCondition) {
            foreach ($ifCondition['tokens'] as $ii => $token) {
                $ifConditions[$kk]['tokens'][$ii]['validToken'] = false;
            }
        }

        // Plugins are responsible for validating all the tokens.
        // If a plugin deemed a token 'valid', it will set 'validToken' to
        // true.
        $this->emit('validateTokens', [$request, &$ifConditions]);

        // Now we're going to analyze the result.

        // Every ifCondition needs to validate to true, so we exit as soon as
        // we have an invalid condition.
        foreach ($ifConditions as $ifCondition) {
            $uri = $ifCondition['uri'];
            $tokens = $ifCondition['tokens'];

            // We only need 1 valid token for the condition to succeed.
            foreach ($tokens as $token) {
                $tokenValid = $token['validToken'] || !$token['token'];

                $etagValid = false;
                if (!$token['etag']) {
                    $etagValid = true;
                }
                // Checking the ETag, only if the token was already deemed
                // valid and there is one.
                if ($token['etag'] && $tokenValid) {
                    // The token was valid, and there was an ETag. We must
                    // grab the current ETag and check it.
                    $node = $this->tree->getNodeForPath($uri);
                    $etagValid = $node instanceof IFile && $node->getETag() == $token['etag'];
                }

                if (($tokenValid && $etagValid) ^ $token['negate']) {
                    // Both were valid, so we can go to the next condition.
                    continue 2;
                }
            }

            // If we ended here, it means there was no valid ETag + token
            // combination found for the current condition. This means we fail!
            throw new Exception\PreconditionFailed('Failed to find a valid token/etag combination for '.$uri, 'If');
        }

        return true;
    }

    /**
     * This method is created to extract information from the WebDAV HTTP 'If:' header.
     *
     * The If header can be quite complex, and has a bunch of features. We're using a regex to extract all relevant information
     * The function will return an array, containing structs with the following keys
     *
     *   * uri   - the uri the condition applies to.
     *   * tokens - The lock token. another 2 dimensional array containing 3 elements
     *
     * Example 1:
     *
     * If: (<opaquelocktoken:181d4fae-7d8c-11d0-a765-00a0c91e6bf2>)
     *
     * Would result in:
     *
     * [
     *    [
     *       'uri' => '/request/uri',
     *       'tokens' => [
     *          [
     *              [
     *                  'negate' => false,
     *                  'token'  => 'opaquelocktoken:181d4fae-7d8c-11d0-a765-00a0c91e6bf2',
     *                  'etag'   => ""
     *              ]
     *          ]
     *       ],
     *    ]
     * ]
     *
     * Example 2:
     *
     * If: </path/> (Not <opaquelocktoken:181d4fae-7d8c-11d0-a765-00a0c91e6bf2> ["Im An ETag"]) (["Another ETag"]) </path2/> (Not ["Path2 ETag"])
     *
     * Would result in:
     *
     * [
     *    [
     *       'uri' => 'path',
     *       'tokens' => [
     *          [
     *              [
     *                  'negate' => true,
     *                  'token'  => 'opaquelocktoken:181d4fae-7d8c-11d0-a765-00a0c91e6bf2',
     *                  'etag'   => '"Im An ETag"'
     *              ],
     *              [
     *                  'negate' => false,
     *                  'token'  => '',
     *                  'etag'   => '"Another ETag"'
     *              ]
     *          ]
     *       ],
     *    ],
     *    [
     *       'uri' => 'path2',
     *       'tokens' => [
     *          [
     *              [
     *                  'negate' => true,
     *                  'token'  => '',
     *                  'etag'   => '"Path2 ETag"'
     *              ]
     *          ]
     *       ],
     *    ],
     * ]
     *
     * @return array
     */
    public function getIfConditions(RequestInterface $request)
    {
        $header = $request->getHeader('If');
        if (!$header) {
            return [];
        }

        $matches = [];

        $regex = '/(?:\<(?P<uri>.*?)\>\s)?\((?P<not>Not\s)?(?:\<(?P<token>[^\>]*)\>)?(?:\s?)(?:\[(?P<etag>[^\]]*)\])?\)/im';
        preg_match_all($regex, $header, $matches, PREG_SET_ORDER);

        $conditions = [];

        foreach ($matches as $match) {
            // If there was no uri specified in this match, and there were
            // already conditions parsed, we add the condition to the list of
            // conditions for the previous uri.
            if (!$match['uri'] && count($conditions)) {
                $conditions[count($conditions) - 1]['tokens'][] = [
                    'negate' => $match['not'] ? true : false,
                    'token' => $match['token'],
                    'etag' => isset($match['etag']) ? $match['etag'] : '',
                ];
            } else {
                if (!$match['uri']) {
                    $realUri = $request->getPath();
                } else {
                    $realUri = $this->calculateUri($match['uri']);
                }

                $conditions[] = [
                    'uri' => $realUri,
                    'tokens' => [
                        [
                            'negate' => $match['not'] ? true : false,
                            'token' => $match['token'],
                            'etag' => isset($match['etag']) ? $match['etag'] : '',
                        ],
                    ],
                ];
            }
        }

        return $conditions;
    }

    /**
     * Returns an array with resourcetypes for a node.
     *
     * @return array
     */
    public function getResourceTypeForNode(INode $node)
    {
        $result = [];
        foreach ($this->resourceTypeMapping as $className => $resourceType) {
            if ($node instanceof $className) {
                $result[] = $resourceType;
            }
        }

        return $result;
    }

    // }}}
    // {{{ XML Readers & Writers

    /**
     * Returns a callback generating a WebDAV propfind response body based on a list of nodes.
     *
     * If 'strip404s' is set to true, all 404 responses will be removed.
     *
     * @param array|\Traversable $fileProperties The list with nodes
     * @param bool               $strip404s
     *
     * @return callable|string
     */
    public function generateMultiStatus($fileProperties, $strip404s = false)
    {
        $w = $this->xml->getWriter();
        if (self::$streamMultiStatus) {
            return function () use ($fileProperties, $strip404s, $w) {
                $w->openUri('php://output');
                $this->writeMultiStatus($w, $fileProperties, $strip404s);
                $w->flush();
            };
        }
        $w->openMemory();
        $this->writeMultiStatus($w, $fileProperties, $strip404s);

        return $w->outputMemory();
    }

    /**
     * @param $fileProperties
     */
    private function writeMultiStatus(Writer $w, $fileProperties, bool $strip404s)
    {
        $w->contextUri = $this->baseUri;
        $w->startDocument();

        $w->startElement('{DAV:}multistatus');

        foreach ($fileProperties as $entry) {
            $href = $entry['href'];
            unset($entry['href']);
            if ($strip404s) {
                unset($entry[404]);
            }
            $response = new Xml\Element\Response(
                ltrim($href, '/'),
                $entry
            );
            $w->write([
                'name' => '{DAV:}response',
                'value' => $response,
            ]);
        }
        $w->endElement();
        $w->endDocument();
    }
}
