<?php
namespace RESTAPI;

use Config;
use Request;
use gossi\docblock\Docblock;

/**
 * RouteMaps define and group routes to resources.
 *
 * Instances of RouteMaps are registered with the RESTAPI\Router to
 * participate in the routing business.
 *
 * A RouteMap defines at least one handler method which has to be
 * annotated with one of these annotations correlating to HTTP request
 * methods:
 *
 * @code
 * / * *
 *  * An example handler method
 *  *
 *  * @get /foo
 *  * @post /bar/:id
 *  * @put /baz/:id/:other_id
 *  * @delete /
 *  * /
 *  public function anyMethodName($id, $other_id = null) {}
 * @endcode
 *
 * By default, all API routes are unaccessible for nobody users.
 * To explicitly allow access for nobody users, add the allow_nobody
 * tag to the handler method's doc block. Example:
 *
 * @code
 * / * *
 *  * Another example handler method
 *  *
 *  * @get /foo
 *  *
 *  * @allow_nobody
 *  * /
 * @endcode
 *
 * As soon as the Router matches a HTTP request to a handler defined
 * in a RouteMap, it calls RouteMap::init to initialize it and
 * especially the instance field `$this->response` of type
 * RESTAPI\Response. You do not call RouteMap::init on your own.
 *
 * After the router has initialized this RouteMap, the router tries to
 * call a method `before` of this signature:
 *
 * @code
 * public function before(Router $router, Array $handler, Array $parameters);
 * @endcode
 *
 * The parameter `$handler` is a callable (as in function is_callable)
 * consisting of the instance of this RouteMap and the name of a
 * method of this instance. You may change the values of this array to
 * redirect to another handler.
 *
 * The parameter `$parameters` is an associative array whose keys
 * correlate to the placeholders in the matched URI template. The
 * values are the actual values of that placeholders in regard to the
 * HTTP request.
 *
 *
 * After calling RouteMap::before control is transfered to the actual
 * handler method. The values of the placeholders in the URI template
 * of the annotation are send as arguments to the handler.
 *
 * Example: We have got this handler method defined:
 *
 * @code
 * / * *
 *  * @get /foo/:id/bar/:other_id
 *  * /
 * public function fooHandler($id, $other_id) {
 * }
 * @endcode
 *
 * The router receives a request like this: `http://[..]/foo/1/bar/2`
 * and matches it to our `fooHandler` which is then called something
 * like that:
 *
 * @code
 * $result = $routeMap->fooHandler(1, 2);
 * @endcode
 *
 * In your handler methods you have to process the input and return
 * some output data, which is then rendered in an appropriate way
 * after negotiating the content format in the Router.
 *
 * Thus the return value of your handler method becomes the body of
 * the HTTP response.
 *
 *
 * The RouteMap class defines several methods to ease up your work
 * with the HTTP specifica.
 *
 * The methods RouteMap::status, RouteMap::headers and RouteMap::body
 * correlate to the components of a HTTP response.
 *
 * There are helpers for returning paginated collections, see
 * RouteMap::paginated.
 *
 * If you encounter an error or have to stop further processing, see
 * methods RouteMap::halt, RouteMap::error and RouteMap::notFound.
 *
 * These methods are \a DISRUPTIVE as they immediately stop the control
 * flow in your handler:
 *
 * @code
 * public function fooHandler($id)
 * {
 *   // do something
 *
 *   $this->halt();
 *
 *   // this line will never be reached
 * }
 * @endcode
 *
 * If you want to simply send a redirection response (HTTP status code
 * of 302 or 303), you may find calling RouteMap::redirect helpful.
 *
 * To generate a URL to a handler, use RouteMap::url
 *
 * When you find the need to return the content of a file, please see
 * RouteMap::sendFile which will help you with streaming it to the
 * client. For custom streaming just return a Closure from your
 * handler method.
 *
 * There are several other methods which you may find useful each
 * matching a HTTP header:
 *
 *   - RouteMap::contentType
 *   - RouteMap::etag
 *   - RouteMap::expires
 *   - RouteMap::cacheControl
 *   - RouteMap::lastModified
 *
 * You can access the data sent in the body of the current HTTP
 * request using the `$this->data` instance variable.
 *
 *   - If the request was of Content-Type `application/json`, the
 *     body of the request is decoded using `json_decode`.
 *   - If the request was of Content-Type
 *     `application/x-www-form-urlencoded`, the body of the request is
 *     decoded using `parse_str`.
 *   - Otherwise the request will not be parsed and `$this->data` will
 *     just contain the raw string.
 *
 * NOTE: The result of the described parsing will always contain
 *       strings encoded in windows-1252. If the original body
 *       was UTF-8 encoded, it is automatically re-encoded to windows-1252.
 *
 * @author     Jan-Hendrik Willms <tleilax+studip@gmail.com>
 * @author     <mlunzena@uos.de>
 * @license    GPL 2 or later
 * @since      Stud.IP 3.0
 * @deprecated Since Stud.IP 5.0. Will be removed in Stud.IP 5.2.
 */
abstract class RouteMap
{
    protected $router;
    protected $route;
    protected $data = null;
    protected $response;

    /**
     * Internal property which is used by RouteMap::paginated and
     * contains everything about a paginated collection.
     */
    protected $pagination = false;

    /**
     * The offset into a RouteMap::paginated collection as requested
     * by the client.
     */
    protected $offset;

    /**
     * The limit of a RouteMap::paginated collection as requested
     * by the client.
     */
    protected $limit;

    /**
     * Constructor of the route map. Initializes neccessary offset and limit
     * parameters for pagination.
     */
    public function __construct()
    {
        $this->offset = Request::int('offset', 0);
        $this->limit  = Request::int('limit', Config::get()->ENTRIES_PER_PAGE);
    }

    /**
     * Initializes the route map by binding it to a router and passing in
     * the current route.
     *
     * @param Router $router Router to bind this route map to
     * @param array $route   The matched route out of Router::matchRoute;
     *                       an array with keys 'handler', 'conditions' and
     *                       'source'
     */
    public function init($router, $route)
    {
        $this->router   = $router;
        $this->route    = $route;
        $this->response = new Response();

        if ($mediaType = $this->getRequestMediaType()) {
            $this->data = $this->parseRequestBody($mediaType);
        }
    }

    /**
     * Marks this chunk of data as a slice of a larger data set with
     * a sum of "total" entries.
     *
     * @param mixed $data         Chunk of data (should be sliced according
     *                            to current offset and limit parameters).
     * @param int   $total        The total number of data entries in the
     *                            according set.
     * @param array $uri_params   Neccessary parameters when generating uris
     *                            for the current route.
     * @param array $query_params Optional query parameters.
     */
    public function paginated($data, $total, $uri_params = [], $query_params = [])
    {
        $uri = $this->url($this->route['uri_template']->inject($uri_params), $query_params);

        $this->paginate($uri, $total);
        return $this->collect($data);
    }


    /**
     * Low level method for paginating collections. You better use
     * RouteMap::paginated instead of this.
     *
     * Set the pagination data used by the RouteMap::collect.
     *
     * @param String $uri_format
     * @param int    $total
     * @param mixed  $offset
     * @param mixed  $limit
     *
     * @return Routemap Returns instance of self to allow chaining
     */
    public function paginate($uri_format, $total, $offset = null, $limit = null)
    {
        $total  = (int)$total;
        $offset = (int)($offset ?: $this->offset ?: 0);
        $limit  = (int)($limit ?: $this->limit);

        $this->pagination = compact('uri_format', 'total', 'offset', 'limit');

        return $this;
    }

    /**
     * Low level method for paginating collections. You better use
     * RouteMap::paginated instead of this.
     *
     * Adjusts the result set to return a collection. A collection consists
     * of the passed data array and the associated pagination information
     * if available.
     *
     * Be aware that the passed data has to be already sliced according to
     * the pagination information.
     *
     * @param array $data Actual dataset
     * @return array Collection "object"
     */
    public function collect($data)
    {
        $collection = [
            'collection' => $data
        ];
        if (is_array($this->pagination)) {
            extract($this->pagination);

            $offset = $offset - $offset % $limit;
            $max    = ($total % $limit)
                    ? $total - $total % $limit
                    : $total - $limit;

            $pagination = compact('total', 'offset', 'limit');
            if ($total > $limit) {
                $links = [];

                foreach ([
                             'first' => 0,
                             'previous' => max(0, $offset - $limit),
                             'next' => min($max, $offset + $limit),
                             'last' => $max]
                         as $key => $offset)
                {
                    $links[$key] = \URLHelper::getURL($uri_format, compact('offset', 'limit'));
                }

                $pagination['links'] = $links;
            }
            $collection['pagination'] = $pagination;
        }
        return $collection;
    }

    /************************/
    /* REQUEST BODY METHODS */
    /************************/

    // find the requested media type
    private function getRequestMediaType()
    {
        if ($contentType = $_SERVER['CONTENT_TYPE']) {
            $contentTypeParts = preg_split('/\s*[;,]\s*/', $contentType);
            return mb_strtolower($contentTypeParts[0]);
        }
    }

    // media-types that we know how to process
    private static $mediaTypes = [
        'application/json' => 'parseJson',
        'application/x-www-form-urlencoded' => 'parseFormEncoded',
        'multipart/form-data' => 'parseMultipartFormdata'
    ];

    // cache the request body
    private static $_request_body;

    // reads the HTTP request body
    private function parseRequestBody($mediaType)
    {
        // read it only once
        if (!isset(self::$_request_body)) {
            self::$_request_body = file_get_contents('php://input');
        }

        if (isset(self::$mediaTypes[$mediaType])) {
            $result = call_user_func([__CLASS__, self::$mediaTypes[$mediaType]], self::$_request_body);
            if ($result) {
                return $result;
            }
        }
        return self::$_request_body;
    }

    // strategy to decode JSON strings
    private static function parseJson($input)
    {
        return json_decode($input, true);
    }

    // strategy to decode form encoded strings
    private static function parseFormEncoded($input)
    {
        parse_str($input, $result);
        return $result;
    }

    // strategy to decode a multipart message. Used for file-uploads.
    private static function parseMultipartFormdata($input)
    {

        $data = [];
        if (Request::isPost()) {
            foreach ($_POST as $key => $value) {
                $data[$key] = $value;
            }
            $data['_FILES'] = $_FILES;
            return $data;
        }
        $boundary = self::getMultipartBoundary();
        if (!$boundary) {
            return $data;
        }
        $input = explode("--".$boundary, $input);

        array_pop($input);
        array_shift($input);

        foreach ($input as $part) {
            $part = ltrim($part, "\r\n");
            list($head, $body) = explode("\r\n\r\n", $part, 2);

            $tmpheaders = $headers = [];
            foreach (explode("\r\n", $head) as $headline) {
                if (preg_match('/^[^\s]/', $headline)) {
                    $lineIsHeader = preg_match('/([^:]+):\s*(.*)$/', $headline, $matches);
                    if ($lineIsHeader) {
                        $tmpheaders[] = ['index' => mb_strtolower(trim($matches[1])), 'value' => trim($matches[2])];
                    }
                } else {
                    //noch zur letzten Zeile hinzuzählen
                    end($tmpheaders);
                    $lastkey = key($tmpheaders);
                    $tmpheaders[$lastkey]['value'] .= " ".mb_substr($headline, 1);
                }
            }
            foreach ($tmpheaders as $header) {
                $headers[$header['index']] = $header['value'];
            }

            $contentType = "";
            if (isset($headers['content-type'])) {
                preg_match("/^([^;\s]*)/", $headers['content-type'], $matches);
                $contentType = mb_strtolower($matches[1]);
            }
            switch ($headers["transfer-encoding"]) {
                case "quoted-printable":
                    $body = quoted_printable_decode($body);
                    break;
                case "base64":
                    $body = base64_decode(preg_replace("/(\r?\n|\r)/", "", trim($body)));
                    break;
                case "7bit":
                case "8bit":
                default:
                    //nothing to do
            }
            $matches = [];
            preg_match("/name=([^;\s]*)/i", $headers['content-disposition'], $matches);
            $name = str_replace(["'", '"'], '', $matches[1]);
            if (!$contentType) {
                $data[$name] = mb_substr($body, 0, mb_strlen($body) - 2);
            } else {
                switch ($contentType) {
                    case 'application/json':
                        $data = array_merge($data, self::parseJson($body));
                        break;
                    case 'application/x-www-form-urlencoded':
                        $data = array_merge($data, self::parseFormEncoded($body));
                        break;
                    default:
                        $matches = [];
                        preg_match("/filename=([^;\s]*)/i", $headers['content-disposition'], $matches);
                        if (!$matches[1]) {
                            preg_match('/filename=([^;\s]*)/i', $headers['content-type'], $matches);
                        }
                        $filename = str_replace(["'", '"'], '', $matches[1]);
                        $tmp_name = $GLOBALS['TMP_PATH']."/uploadfile_".md5(uniqid());
                        $handle = fopen($tmp_name, 'wb');
                        $filesize = fwrite($handle, $body, (mb_strlen($body) - 2));
                        fclose($handle);
                        $data['_FILES'][$name] = [
                            'name' => $filename,
                            'type' => $contentType,
                            'tmp_name' => $tmp_name,
                            'size' => $filesize
                        ];
                }
            }
        }
        return $data;
    }

    private static function getMultipartBoundary()
    {
        if ($contentType = $_SERVER['CONTENT_TYPE']) {
            foreach (preg_split('/\s*[;,]\s*/', $contentType) as $part) {
                if (mb_strtolower(mb_substr($part, 0, 8)) === "boundary") {
                    $part = explode("=", $part);
                    return $part[1];
                }
            }
        }
        return null;
    }


    /**
     * Set the HTTP status of the current response.
     *
     * @param integer $status  the HTTP status of the response
     */
    public function status($status)
    {
        $this->response->status = $status;
    }

    /**
     * Set multiple response headers of the current response by
     * merging them with already set ones.
     *
     * @code
     * $routemap->headers(array('X-example' => "yep"));
     * @endcode
     *
     * @param array $headers  the headers to set
     *
     * @return array  the headers of the current response
     */
    public function headers($headers = [])
    {
        if (sizeof($headers)) {
            $this->response->headers = array_merge($this->response->headers, $headers);
        }
        return $this->response->headers;
    }

    /**
     * Set the HTTP body of the current response.
     *
     * @param string $body  the body to send back
     */
    public function body($body)
    {
        $this->response->body = $body;
    }


    /**
     * Set the Content-Type of the HTTP response given a mime type and
     * optionally further parameters as discusses in RFC 2616 14.17.
     *
     * If no charset is given, it defaults to Stud.IP's 'windows-1252'.
     *
     * Examples:
     *
     * @code
     * // results in "Content-Type: image/gif"
     * $this->contentType('image/gif);
     *
     * // results in "Content-Type: text/html;charset=ISO-8859-4"
     * $this->contentType('text/html;charset=ISO-8859-4');
     *
     * // results in "Content-Type: text/html;charset=ISO-8859-4"
     * $this->contentType('text/html', array('charset' => 'ISO-8859-4'));
     *
     * // results in "Content-type: multipart/byteranges; boundary=THIS_STRING_SEPARATES"
     * $this->contentType('multipart/byteranges', array('boundary' => 'THIS_STRING_SEPARATES'));
     *
     * @endcode
     *
     * @param string $mime_type  a string describing a MIME type like 'application/json'
     * @param array  $params     optional parameters as described above
     */
    public function contentType($mime_type, $params = [])
    {
        if (!isset($params['charset'])) {
            $params['charset'] = 'utf-8';
        }

        if (mb_strpos($mime_type, 'charset') !== FALSE) {
            unset($params['charset']);
        }

        if (sizeof($params)) {
            $mime_type .= mb_strpos($mime_type, ';') !== FALSE ? ', ' : ';';
            $ps = [];
            foreach ($params as $k => $v) {
                $ps[] = $k . '=' . $v;
            }
            $mime_type .= join(', ', $ps);
        }

        $this->response['Content-Type'] = $mime_type;
    }

    /**
     * (Nice) sugar for calling RouteMap::halt and therefore
     * as \a DISRUPTIVE. Code after calling RouteMap::error will not
     * be evaluated.
     *
     * @see RouteMap::halt
     *
     * @param integer $status  a number indicating the HTTP status
     *                         code; probably something 4xx or 5xx-ish
     * @param string $body     optional; the body of the HTTP response
     *
     */
    public function error($status, $body = null)
    {
        $this->halt($status, [], $body);
    }


    /**
     * Sets the HTTP response's Etag header and halts, if the incoming
     * HTTP request was a matching conditional GET using an
     * 'If-None-Match' header. Thus it is a possibly \a DISRUPTIVE
     * method as it will stop evaluation in that case and send a '304
     * Not Modified'.
     *
     * Detail: If the request contains an If-Match or If-None-Match
     * header set to `*`, a RouteMap assumes a match on safe
     * (e.g. GET) and idempotent (e.g. PUT) requests. (In those cases
     * it thinks that the resource already exists and therefore
     * matches a wildcard.). This can be changed by passing an
     * appropriate value for the `$new_resource` parameter.

     * Details of this can be found in RFC 2616 14.24 and 14.26
     *
     * @param string $value       an identifier uniquely identifying the
     *                            current state of a resource
     * @param bool $strong_etag   optional; indicates whether the etag
     *                            is a weak or strong (which is the
     *                            default) cache validator. Have a look
     *                            at the RFC for details.
     * @param bool $new_resource  optional; a way to tell the RouteMap
     *                            that this is a new or existing
     *                            resource. See above.
     */

    public function etag($value, $strong_etag = true, $new_resource = null)
    {
        // Before touching this code, please double check RFC 2616
        // 14.24 and 14.26.

        if (!isset($new_resource)) {
            $new_resource = Request::isPost();
        }

        $value = '"' . $value . '"';
        if (!$strong_etag) {
            $value = 'W/' . $value;
        }
        $this->response['ETag'] = $value;

        if ($this->response->isSuccess() || $this->response->status === 304) {
            if ($this->etagMatches($_SERVER['HTTP_IF_NONE_MATCH'], $new_resource)) {
                $this->halt($this->isRequestSafe() ? 304 : 412);
            }
            if (isset($_SERVER['HTTP_IF_MATCH'])
                && !$this->etagMatches($_SERVER['HTTP_IF_MATCH'], $new_resource)) {
                $this->halt(412);
            }
        }
    }

    // Helper method checking if a ETag value list includes the current ETag.
    private function etagMatches($list, $new_resource)
    {
        if ($list === '*') {
            return !$new_resource;
        }

        return in_array($this->response['ETag'],
                        preg_split('/\s*,\s*/', $list));
    }

    // Helper method checking if the request is safe
    private function isRequestSafe()
    {
        $method = Request::method();
        return $method === 'GET' or $method === 'HEAD' or $method === 'OPTIONS' or $method === 'TRACE';
    }

    /**
     * This sets the `Expires` header and the `Cache-Control`
     * directive `max-age`.
     *
     * Amount is an integer number of seconds in the future indicating
     * when the response should be considered "stale". The
     * `$cache_control` parameter is passed to RouteMap#cacheControl
     * along with the automatically generated `max_age` directive.
     *
     * @param int $amount  an integer specifying the number of seconds
     *                     this resource will go stale.
     * @param array $cache_control  optional; more directives for
     *                     RouteMap::cacheControl which is always
     *                     automatically called using the computed max_age
     */
    public function expires($amount, $cache_control = [])
    {
        $time = time() + $amount;
        $max_age = $amount;

        $cache_control[] = "max-age=$max_age";
        $this->cacheControl($cache_control);

        $this->response['Expires'] = $this->httpDate($time);
    }

    /**
     * This sets the Cache-Control header of the HTTP response.
     *
     * Example:
     *
     * @code
     * $this->cacheControl(array('public', 'must-revalidate'));
     * @endcode
     *
     * @param array $values  an array containing Cache-Control
     *                       directives.
     */
    public function cacheControl($values)
    {
        if (is_array($values) && sizeof($values)) {
            $this->response['Cache-Control'] = join(', ', $values);
        }
    }

    /**
     * This very important method stops further execution of your
     * code. You may specify a status code, headers and the body of
     * the resulting response. As the name implies, this method is \a
     * DISRUPTIVE and will not return.
     *
     * @code
     * // stops any further code of a route
     * $this->halt();
     *
     * // you may specify an HTTP status
     * $this->halt(409):
     *
     * // you may specify the HTTP response's body
     * $this->halt('my ethereal body')
     *
     * // or even both
     * $this->halt(100, 'Yes, pleazze!')
     *
     * // giving headers
     * $this->halt(417, array('Content-Type' => 'x-not-a-cat'), 'Cats only!')
     * @endcode
     *
     * This method is called by every single \a DISRUPTIVE method.
     *
     * @param integer $status  optional; the response's status code
     * @param array $headers   optional; (additional) header lines
     *                           which get merged with already set headers
     * @param string $body     optional; the response's body
     */
    public function halt(/* [status], [headers], [body] */)
    {
        $args   = func_get_args();
        $result = [];

        $constraints = [
            'status'  => 'is_int',
            'headers' => 'is_array',
            'body'    => function ($i) { return isset($i); } // #existy
        ];
        foreach ($constraints as $state => $constraint) {
            if ($constraint(current($args))) {
                call_user_func([$this, $state], array_shift($args));
            }
        }

        throw new RouterHalt($this->response);
    }

    /**
     * This method sets the Last-Modified header of the HTTP response
     * and halts on matching conditional GET requests. Thus this
     * method is \a DISRUPTIVE in certain circumstances.
     *
     * You have to give an integer typed timestamp (in seconds since
     * epoch) to specify the data of the last modification to the
     * requested resource.
     *
     * If the current HTTP request contains an `If-Modified-Since`
     * header, its value is compared to the specified `$time`
     * parameter. Unless the header's value is sooner than the given
     * `$time`, further execution is precluded and the RouteMap
     * returns with a '304 Not Modified'.
     *
     * @param integer $time  a timestamp described in seconds since epoch
     */
    public function lastModified($time)
    {

        $this->response['Last-Modified'] = $this->httpDate($time);

        if (isset($_SERVER['HTTP_IF_NONE_MATCH'])) {
            return;
        }

        if ($this->response->status === 200
            && isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])) {
            // compare based on seconds since epoch
            $since = $this->httpdate($_SERVER['HTTP_IF_MODIFIED_SINCE']);
            if ($since >= (int) $time) {
                $this->halt(304);
            }
        }

        if (($this->response->isSuccess() || $this->response->status === 412)
            && isset($_SERVER['HTTP_IF_UNMODIFIED_SINCE'])) {

            // compare based on seconds since epoch
            $since = $this->httpdate($_SERVER['HTTP_IF_UNMODIFIED_SINCE']);

            if ($since < (int) $time) {
                $this->halt(412);
            }
        }
    }

    private function httpDate($timestamp)
    {
        return gmdate('D, d M Y H:i:s \G\M\T', (int) $timestamp);
    }

    /**
     * Halts execution and returns a '404 Not Found' response.
     *
     * Sugar for calling RouteMap::error(404) and therefore
     * \a DISRUPTIVE. Code after calling RouteMap::notFound will
     * not be evaluated.
     *
     * @see RouteMap::error
     * @see RouteMap::halt
     *
     * @param string $body     optional; the body of the HTTP response
     */
    public function notFound($body = null)
    {
        $this->halt(404, $body);
    }

    /**
     * Stops your code and redirects to the URL provided. This method
     * is \a DISRUPTIVE like RouteMap#halt
     *
     * In addition to the URL you may provide the status code,
     * (additional) headers and a request body as you would when
     * calling RouteMap#halt.
     *
     * @code
     * $this->redirect('/foo', 201, array('X-Some-Header' => 1234), 'and even a body');
     * @endcode
     *
     * @see RouteMap::halt
     *
     * @param string $url the URL to redirect to; it will be filtered
     *                    using RouteMap#url, so you may call it with
     *                    those nice and small strings used in the
     *                    annotations
     * @param mixed $args optional; any combinations of the three
     *                    parameters as in RouteMap::halt
     */
    public function redirect($url, $args = null)
    {
        $this->status($_SERVER["SERVER_PROTOCOL"] === 'HTTP/1.1' && !Request::isGet() ? 303 : 302);
        $this->response['Location'] = $this->url($url);

        $args = array_slice(func_get_args(), 1);
        call_user_func_array([$this, 'halt'], $args);
    }


    /**
     * Stops execution of your code and starts sending the specified
     * file. This method is \a DISRUPTIVE.
     *
     * Using the `$opts` parameter you may specify the file's mime
     * content type, sending an appropriate 'Content-Type' header, and
     * you may specify the 'Content-Disposition' of the file transfer.
     *
     * Example:
     *
     * @code
     * $this->sendFile('/tmp/c29tZSB0ZXh0', array(
     *     'type' => 'image/png',
     *     'disposition' => 'inline',
     *     'filename' => 'cutecats.png'));
     * @endcode
     *
     * @param string $_path  the filesystem path to the file to send
     * @param array  $opts   optional; specify the content type,
     *                       disposition and filename
     */
    public function sendFile($_path, $opts = [])
    {
        $path = realpath($_path);

        if (!file_exists($path)) {
            $this->notFound('File to send does not exist');
        }

        if (isset($opts['type'])) {
            $this->contentType($opts['type']);
        } else if (!isset($this->response['Content-Type'])) {
            $this->contentType(get_mime_type($path));
        }

        if ($opts['disposition'] === 'attachment' || isset($opts['filename'])) {
            $this->response['Content-Disposition'] = 'attachment; ';
            $filename = $opts['filename'] ?: $path;
            $this->response['Content-Disposition'] .= encode_header_parameter('filename', basename($filename));
        }

        elseif ($opts['disposition'] === 'inline') {
            $this->response['Content-Disposition'] = 'inline';
        }

        // TODO add HTTP 'Range' support

        $size = filesize($path);
        $this->response['Content-Length'] = $size;

        // End all potential output buffers
        while (ob_get_level() > 0) {
            ob_end_clean();
        }

        // Send file
        $this->halt(200, $this->response->headers, function () use ($path) {
            readfile($path);
        });
    }


    /**
     * Generate a URL to a given handler using a URL fragment and URL
     * parameters.
     *
     * Example:
     * @code
     * // result in something like "/some/path/api.php/course/123/members?status=student"
     * $this->url('course/123/members', array('status' => 'student'));
     * @endcode
     *
     * @param string $addr       a URL fragment to a handler
     * @param array $url_params  optional; URL parameters to add to
     *                           the generated URL
     *
     * @return string  the resulting URL
     */
    public function url($addr, $url_params = null)
    {
        $addr = ltrim($addr, '/');
        return \URLHelper::getURL("api.php/$addr", $url_params, true);
    }

    /**
     * A `vsprintf` like variant to the RouteMap::url method.
     *
     * Example:
     * @code
     * // results in "[...]/api.php/foo/some_id?status=student"
     * $this->urlf("foo/%s", array("some_id"), array('status' => 'student'));
     * @endcode
     *
     * @param string $addr_f        a URL fragment to a handler
     *                              containing sprintf-ish format sequences
     * @param array $format_params  values to fill into the format markers
     * @param array $url_params     optional; URL parameters to add to
     *                              the generated URL
     *
     * @return string  the resulting URL
     */

    public function urlf($addr_f, $format_params, $url_params = null)
    {
        return $this->url(vsprintf($addr_f, $format_params), $url_params);
    }

    /**
     * Returns a list of all the routes this routemap provides.
     *
     * @param string $http_method Return only the routes for this specific
     *                            http method (optional)
     *
     * @return array of all routes grouped by method
     */
    public function getRoutes($http_method = null)
    {
        $ref      = new \ReflectionClass($this);
        $docblock = new Docblock($ref);
        $class_conditions = $this->extractConditions($docblock);

        // Create result array by creating an associative array from all
        // supported methods as keys
        $routes = array_fill_keys(Router::getSupportedMethods(), []);

        // Restrict routes to given http method (if given)
        if ($http_method !== null) {
            $routes = [$http_method => []];
        }

        // Iterate through all methods of the routemap
        foreach ($ref->getMethods() as $ref_method) {
            // Not public? Not an api route!
            if (!$ref_method->isPublic()) {
                continue;
            }

            // Parse docblock
            $docblock = new Docblock($ref_method);

            // No docblock tags? Not an api route!
            if ($docblock->getTags()->isEmpty()) {
                continue;
            }

            // Any specific condition to consider?
            $conditions = $this->extractConditions($docblock, $class_conditions);

            // Iterate through all possible methods in order to identify
            // any according docblock tags
            $allow_nobody = $docblock->hasTag('allow_nobody');
            foreach (array_keys($routes) as $http_method) {
                if (!$docblock->hasTag($http_method)) {
                    //The tag for the current HTTP method cannot be found
                    //in the route's DocBlock tags.
                    continue;
                }

                // Route all defined method and uri template combinations to
                // the according methods of the object.
                foreach ($docblock->getTags($http_method) as $tag) {
                    $uri_template = trim($tag->getDescription());
                    $routes[$http_method][$uri_template] = [
                        'handler'      => [$this, $ref_method->name],
                        'conditions'   => $conditions,
                        'description'  => trim($docblock->getShortDescription()) ?: false,
                        'allow_nobody' => $allow_nobody
                    ];
                }
            }
        }

        // Return all routes grouped or just the routes for the wanted method
        return func_num_args() === 1
             ? reset($routes)
             : $routes;
    }

    /**
     * Extracts defined conditions from a given docblock.
     *
     * @param Docblock $docblock   DocBlock to examine
     * @param array    $conditions Optional array of already defined
     *                             conditions to extend
     * @return array of all extracted conditions with the variable name
     *         as key and pattern to match as value
     */
    protected function extractConditions($docblock, $conditions = [])
    {
        foreach ($docblock->getTags('condition') as $condition) {
            list($var, $pattern) = explode(' ', $condition->getDescription(), 2);
            $conditions[$var] = $pattern;
        }

        return $conditions;
    }

    /**
     * Returns the response object
     * @return Response
     */
    public function getResponse(): Response
    {
        return $this->response;
    }
}