<?php

namespace BetterMessages\React\Http\Message;

use BetterMessages\Psr\Http\Message\ServerRequestInterface;
use BetterMessages\Psr\Http\Message\StreamInterface;
use BetterMessages\Psr\Http\Message\UriInterface;
use BetterMessages\React\Http\Io\AbstractRequest;
use BetterMessages\React\Http\Io\BufferedBody;
use BetterMessages\React\Http\Io\HttpBodyStream;
use BetterMessages\React\Stream\ReadableStreamInterface;

/**
 * Respresents an incoming server request message.
 *
 * This class implements the
 * [PSR-7 `ServerRequestInterface`](https://www.php-fig.org/psr/psr-7/#321-psrhttpmessageserverrequestinterface)
 * which extends the
 * [PSR-7 `RequestInterface`](https://www.php-fig.org/psr/psr-7/#32-psrhttpmessagerequestinterface)
 * which in turn extends the
 * [PSR-7 `MessageInterface`](https://www.php-fig.org/psr/psr-7/#31-psrhttpmessagemessageinterface).
 *
 * This is mostly used internally to represent each incoming request message.
 * Likewise, you can also use this class in test cases to test how your web
 * application reacts to certain HTTP requests.
 *
 * > Internally, this implementation builds on top of a base class which is
 *   considered an implementation detail that may change in the future.
 *
 * @see ServerRequestInterface
 */
final class ServerRequest extends AbstractRequest implements ServerRequestInterface
{
    private $attributes = array();

    private $serverParams;
    private $fileParams = array();
    private $cookies = array();
    private $queryParams = array();
    private $parsedBody;

    /**
     * @param string                                         $method       HTTP method for the request.
     * @param string|UriInterface                            $url          URL for the request.
     * @param array<string,string|string[]>                  $headers      Headers for the message.
     * @param string|ReadableStreamInterface|StreamInterface $body         Message body.
     * @param string                                         $version      HTTP protocol version.
     * @param array<string,string>                           $serverParams server-side parameters
     * @throws \InvalidArgumentException for an invalid URL or body
     */
    public function __construct(
        $method,
        $url,
        array $headers = array(),
        $body = '',
        $version = '1.1',
        $serverParams = array()
    ) {
        if (\is_string($body)) {
            $body = new BufferedBody($body);
        } elseif ($body instanceof ReadableStreamInterface && !$body instanceof StreamInterface) {
            $temp = new self($method, '', $headers);
            $size = (int) $temp->getHeaderLine('Content-Length');
            if (\strtolower($temp->getHeaderLine('Transfer-Encoding')) === 'chunked') {
                $size = null;
            }
            $body = new HttpBodyStream($body, $size);
        } elseif (!$body instanceof StreamInterface) {
            throw new \InvalidArgumentException('Invalid server request body given');
        }

        parent::__construct($method, $url, $headers, $body, $version);

        $this->serverParams = $serverParams;

        $query = $this->getUri()->getQuery();
        if ($query !== '') {
            \parse_str($query, $this->queryParams);
        }

        // Multiple cookie headers are not allowed according
        // to https://tools.ietf.org/html/rfc6265#section-5.4
        $cookieHeaders = $this->getHeader("Cookie");

        if (count($cookieHeaders) === 1) {
            $this->cookies = $this->parseCookie($cookieHeaders[0]);
        }
    }

    public function getServerParams()
    {
        return $this->serverParams;
    }

    public function getCookieParams()
    {
        return $this->cookies;
    }

    public function withCookieParams(array $cookies)
    {
        $new = clone $this;
        $new->cookies = $cookies;
        return $new;
    }

    public function getQueryParams()
    {
        return $this->queryParams;
    }

    public function withQueryParams(array $query)
    {
        $new = clone $this;
        $new->queryParams = $query;
        return $new;
    }

    public function getUploadedFiles()
    {
        return $this->fileParams;
    }

    public function withUploadedFiles(array $uploadedFiles)
    {
        $new = clone $this;
        $new->fileParams = $uploadedFiles;
        return $new;
    }

    public function getParsedBody()
    {
        return $this->parsedBody;
    }

    public function withParsedBody($data)
    {
        $new = clone $this;
        $new->parsedBody = $data;
        return $new;
    }

    public function getAttributes()
    {
        return $this->attributes;
    }

    public function getAttribute($name, $default = null)
    {
        if (!\array_key_exists($name, $this->attributes)) {
            return $default;
        }
        return $this->attributes[$name];
    }

    public function withAttribute($name, $value)
    {
        $new = clone $this;
        $new->attributes[$name] = $value;
        return $new;
    }

    public function withoutAttribute($name)
    {
        $new = clone $this;
        unset($new->attributes[$name]);
        return $new;
    }

    /**
     * @param string $cookie
     * @return array
     */
    private function parseCookie($cookie)
    {
        $cookieArray = \explode(';', $cookie);
        $result = array();

        foreach ($cookieArray as $pair) {
            $pair = \trim($pair);
            $nameValuePair = \explode('=', $pair, 2);

            if (\count($nameValuePair) === 2) {
                $key = $nameValuePair[0];
                $value = \urldecode($nameValuePair[1]);
                $result[$key] = $value;
            }
        }

        return $result;
    }

    /**
     * [Internal] Parse incoming HTTP protocol message
     *
     * @internal
     * @param string $message
     * @param array<string,string|int|float> $serverParams
     * @return self
     * @throws \InvalidArgumentException if given $message is not a valid HTTP request message
     */
    public static function parseMessage($message, array $serverParams)
    {
        // parse request line like "GET /path HTTP/1.1"
        $start = array();
        if (!\preg_match('#^(?<method>[^ ]+) (?<target>[^ ]+) HTTP/(?<version>\d\.\d)#m', $message, $start)) {
            throw new \InvalidArgumentException('Unable to parse invalid request-line');
        }

        // only support HTTP/1.1 and HTTP/1.0 requests
        if ($start['version'] !== '1.1' && $start['version'] !== '1.0') {
            throw new \InvalidArgumentException('Received request with invalid protocol version', Response::STATUS_VERSION_NOT_SUPPORTED);
        }

        // check number of valid header fields matches number of lines + request line
        $matches = array();
        $n = \preg_match_all(self::REGEX_HEADERS, $message, $matches, \PREG_SET_ORDER);
        if (\substr_count($message, "\n") !== $n + 1) {
            throw new \InvalidArgumentException('Unable to parse invalid request header fields');
        }

        // format all header fields into associative array
        $host = null;
        $headers = array();
        foreach ($matches as $match) {
            $headers[$match[1]][] = $match[2];

            // match `Host` request header
            if ($host === null && \strtolower($match[1]) === 'host') {
                $host = $match[2];
            }
        }

        // scheme is `http` unless TLS is used
        $scheme = isset($serverParams['HTTPS']) ? 'https://' : 'http://';

        // default host if unset comes from local socket address or defaults to localhost
        $hasHost = $host !== null;
        if ($host === null) {
            $host = isset($serverParams['SERVER_ADDR'], $serverParams['SERVER_PORT']) ? $serverParams['SERVER_ADDR'] . ':' . $serverParams['SERVER_PORT'] : '127.0.0.1';
        }

        if ($start['method'] === 'OPTIONS' && $start['target'] === '*') {
            // support asterisk-form for `OPTIONS *` request line only
            $uri = $scheme . $host;
        } elseif ($start['method'] === 'CONNECT') {
            $parts = \parse_url('tcp://' . $start['target']);

            // check this is a valid authority-form request-target (host:port)
            if (!isset($parts['scheme'], $parts['host'], $parts['port']) || \count($parts) !== 3) {
                throw new \InvalidArgumentException('CONNECT method MUST use authority-form request target');
            }
            $uri = $scheme . $start['target'];
        } else {
            // support absolute-form or origin-form for proxy requests
            if ($start['target'][0] === '/') {
                $uri = $scheme . $host . $start['target'];
            } else {
                // ensure absolute-form request-target contains a valid URI
                $parts = \parse_url($start['target']);

                // make sure value contains valid host component (IP or hostname), but no fragment
                if (!isset($parts['scheme'], $parts['host']) || $parts['scheme'] !== 'http' || isset($parts['fragment'])) {
                    throw new \InvalidArgumentException('Invalid absolute-form request-target');
                }

                $uri = $start['target'];
            }
        }

        $request = new self(
            $start['method'],
            $uri,
            $headers,
            '',
            $start['version'],
            $serverParams
        );

        // only assign request target if it is not in origin-form (happy path for most normal requests)
        if ($start['target'][0] !== '/') {
            $request = $request->withRequestTarget($start['target']);
        }

        if ($hasHost) {
            // Optional Host request header value MUST be valid (host and optional port)
            $parts = \parse_url('http://' . $request->getHeaderLine('Host'));

            // make sure value contains valid host component (IP or hostname)
            if (!$parts || !isset($parts['scheme'], $parts['host'])) {
                $parts = false;
            }

            // make sure value does not contain any other URI component
            if (\is_array($parts)) {
                unset($parts['scheme'], $parts['host'], $parts['port']);
            }
            if ($parts === false || $parts) {
                throw new \InvalidArgumentException('Invalid Host header value');
            }
        } elseif (!$hasHost && $start['version'] === '1.1' && $start['method'] !== 'CONNECT') {
            // require Host request header for HTTP/1.1 (except for CONNECT method)
            throw new \InvalidArgumentException('Missing required Host request header');
        } elseif (!$hasHost) {
            // remove default Host request header for HTTP/1.0 when not explicitly given
            $request = $request->withoutHeader('Host');
        }

        // ensure message boundaries are valid according to Content-Length and Transfer-Encoding request headers
        if ($request->hasHeader('Transfer-Encoding')) {
            if (\strtolower($request->getHeaderLine('Transfer-Encoding')) !== 'chunked') {
                throw new \InvalidArgumentException('Only chunked-encoding is allowed for Transfer-Encoding', Response::STATUS_NOT_IMPLEMENTED);
            }

            // Transfer-Encoding: chunked and Content-Length header MUST NOT be used at the same time
            // as per https://tools.ietf.org/html/rfc7230#section-3.3.3
            if ($request->hasHeader('Content-Length')) {
                throw new \InvalidArgumentException('Using both `Transfer-Encoding: chunked` and `Content-Length` is not allowed', Response::STATUS_BAD_REQUEST);
            }
        } elseif ($request->hasHeader('Content-Length')) {
            $string = $request->getHeaderLine('Content-Length');

            if ((string)(int)$string !== $string) {
                // Content-Length value is not an integer or not a single integer
                throw new \InvalidArgumentException('The value of `Content-Length` is not valid', Response::STATUS_BAD_REQUEST);
            }
        }

        return $request;
    }
}
