<?php

namespace Snaju\Inception\Http\Response;

// TODO: implement Psr ResponseInterface
//use Psr\Http\Message\ResponseInterface;
//use Psr\Http\Message\StreamInterface;
use Snaju\Inception\Exception\InvalidArgumentException;
use Snaju\Inception\Exception\InvalidCallException;
use Snaju\Inception\Exception\InvalidConfigException;
use Snaju\Inception\Http\Response\Enums\ResponseFormat;
use Snaju\Inception\Http\Response\Formatters\HtmlResponseFormatter;
use Snaju\Inception\Http\Response\Formatters\JsonResponseFormatter;
use Snaju\Inception\Http\Response\Formatters\RawResponseFormatter;
use Snaju\Inception\Http\Response\Formatters\ResponseFormatterInterface;
use Snaju\Inception\Http\Response\Formatters\TextResponseFormatter;
use Snaju\Inception\Http\Response\Formatters\XmlResponseFormatter;
use Snaju\Inception\InceptionCore;
use Snaju\Inception\Util\Loaders\Collection;

class Response
{

    private array $defaultFormatters = [
        ResponseFormat::RAW => [
            'class' => RawResponseFormatter::class,
            'options' => []
        ],
        ResponseFormat::HTML => [
            'class' => HtmlResponseFormatter::class,
            'options' => []
        ],
        ResponseFormat::JSON => [
            'class' => JsonResponseFormatter::class,
            'options' => []
        ],
        ResponseFormat::JSONP => [
            'class' => JsonResponseFormatter::class,
            'options' => [
                'jsonp' => true,
            ]
        ],
        ResponseFormat::XML => [
            'class' => XmlResponseFormatter::class,
            'options' => []
        ],
        ResponseFormat::TEXT => [
            'class' => TextResponseFormatter::class,
            'options' => []
        ],
    ];

    public string $format = ResponseFormat::JSON;

    public string $acceptMimeType;

    public array $acceptParams = [];

    public array $formatters = [];

    /**
     * Raw data that will be formatted according to the set format. After
     * formatting, `$this->content` will be set.
     * @var mixed
     * @see content
     */
    public $data;

    /**
     * Formatted data according to the set formatter. This is set automatically
     * in the prepare method while the response is being sent. No content will
     * be set if $this->data is empty.
     * @var mixed
     * @see data
     */
    public $content;

    /**
     * @var mixed
     */
    public $stream;

    public string $charset = 'UTF-8';

    public string $statusText = 'OK';

    public string $protocolVersion;

    public bool $isSent = false;

    public static array $httpStatusCodes = [
        100 => 'Continue',
        101 => 'Switching Protocols',
        102 => 'Processing',
        118 => 'Connection timed out',
        200 => 'OK',
        201 => 'Created',
        202 => 'Accepted',
        203 => 'Non-Authoritative',
        204 => 'No Content',
        205 => 'Reset Content',
        206 => 'Partial Content',
        207 => 'Multi-Status',
        208 => 'Already Reported',
        210 => 'Content Different',
        226 => 'IM Used',
        300 => 'Multiple Choices',
        301 => 'Moved Permanently',
        302 => 'Found',
        303 => 'See Other',
        304 => 'Not Modified',
        305 => 'Use Proxy',
        306 => 'Reserved',
        307 => 'Temporary Redirect',
        308 => 'Permanent Redirect',
        310 => 'Too many Redirect',
        400 => 'Bad Request',
        401 => 'Unauthorized',
        402 => 'Payment Required',
        403 => 'Forbidden',
        404 => 'Not Found',
        405 => 'Method Not Allowed',
        406 => 'Not Acceptable',
        407 => 'Proxy Authentication Required',
        408 => 'Request Time-out',
        409 => 'Conflict',
        410 => 'Gone',
        411 => 'Length Required',
        412 => 'Precondition Failed',
        413 => 'Request Entity Too Large',
        414 => 'Request-URI Too Long',
        415 => 'Unsupported Media Type',
        416 => 'Requested range unsatisfiable',
        417 => 'Expectation failed',
        418 => 'I\'m a teapot',
        421 => 'Misdirected Request',
        422 => 'Unprocessable entity',
        423 => 'Locked',
        424 => 'Method failure',
        425 => 'Unordered Collection',
        426 => 'Upgrade Required',
        428 => 'Precondition Required',
        429 => 'Too Many Requests',
        431 => 'Request Header Fields Too Large',
        449 => 'Retry With',
        450 => 'Blocked by Windows Parental Controls',
        451 => 'Unavailable For Legal Reasons',
        500 => 'Internal Server Error',
        501 => 'Not Implemented',
        502 => 'Bad Gateway or Proxy Error',
        503 => 'Service Unavailable',
        504 => 'Gateway Time-out',
        505 => 'HTTP Version not supported',
        507 => 'Insufficient storage',
        508 => 'Loop Detected',
        509 => 'Bandwidth Limit Exceeded',
        510 => 'Not Extended',
        511 => 'Network Authentication Required',
    ];

    private int $statusCode = 200;

    private Collection $headers;

    private Collection $cookies;

    private function getDefaultFormatters(): array
    {
        return $this->defaultFormatters;
    }

    public function __construct()
    {
        $this->protocolVersion = InceptionCore::getRequest()->getProtocolVersion();
        $this->formatters = array_merge($this->getDefaultFormatters(), $this->formatters);
        $this->headers = new Collection;
        $this->cookies = new Collection;
        $this->data = [];
        $this->content = [];
    }


    /**
     * Retrieves the HTTP protocol version as a string.
     *
     * The string MUST contain only the HTTP version number (e.g., "1.1", "1.0").
     *
     * @return string HTTP protocol version.
     */
    public function getProtocolVersion(): string
    {
        return $this->protocolVersion;
    }

//    /**
//     * Return an instance with the specified HTTP protocol version.
//     *
//     * The version string MUST contain only the HTTP version number (e.g.,
//     * "1.1", "1.0").
//     *
//     * This method MUST be implemented in such a way as to retain the
//     * immutability of the message, and MUST return an instance that has the
//     * new protocol version.
//     *
//     * @param string $version HTTP protocol version
//     * @return static
//     */
//    public function withProtocolVersion($version)
//    {
//        // TODO: Implement withProtocolVersion() method.
//    }

    public function getHeaderCollection(): Collection
    {
        return $this->headers;
    }

    /**
     * Retrieves all message header values.
     *
     * The keys represent the header name as it will be sent over the wire, and
     * each value is an array of strings associated with the header.
     *
     *     // Represent the headers as a string
     *     foreach ($message->getHeaders() as $name => $values) {
     *         echo $name . ": " . implode(", ", $values);
     *     }
     *
     *     // Emit headers iteratively:
     *     foreach ($message->getHeaders() as $name => $values) {
     *         foreach ($values as $value) {
     *             header(sprintf('%s: %s', $name, $value), false);
     *         }
     *     }
     *
     * While header names are not case-sensitive, getHeaders() will preserve the
     * exact case in which headers were originally specified.
     *
     * @return string[][] Returns an associative array of the message's headers. Each
     *     key MUST be a header name, and each value MUST be an array of strings
     *     for that header.
     */
    public function getHeaders(): array
    {
        return $this->headers->toArray();
    }

    /**
     * Checks if a header exists by the given case-insensitive name.
     *
     * @param string $name Case-insensitive header field name.
     * @return bool Returns true if any header names match the given header
     *     name using a case-insensitive string comparison. Returns false if
     *     no matching header name is found in the message.
     */
    public function hasHeader($name): bool
    {
        return $this->headers->keyExists($name);
    }

//    /**
//     * Retrieves a message header value by the given case-insensitive name.
//     *
//     * This method returns an array of all the header values of the given
//     * case-insensitive header name.
//     *
//     * If the header does not appear in the message, this method MUST return an
//     * empty array.
//     *
//     * @param string $name Case-insensitive header field name.
//     * @return string[] An array of string values as provided for the given
//     *    header. If the header does not appear in the message, this method MUST
//     *    return an empty array.
//     */
//    public function getHeader($name): array
//    {
//        // TODO: implement getHeader method
//    }

    /**
     * Retrieves a comma-separated string of the values for a single header.
     *
     * This method returns all of the header values of the given
     * case-insensitive header name as a string concatenated together using
     * a comma.
     *
     * NOTE: Not all header values may be appropriately represented using
     * comma concatenation. For such headers, use getHeader() instead
     * and supply your own delimiter when concatenating.
     *
     * If the header does not appear in the message, this method MUST return
     * an empty string.
     *
     * @param string $name Case-insensitive header field name.
     * @return string A string of values as provided for the given header
     *    concatenated together using a comma. If the header does not appear in
     *    the message, this method MUST return an empty string.
     */
    public function getHeaderLine($name): string
    {
        if ($this->headers->keyExists($name)) {
            return $this->headers->get($name);
        }
            else {
                return '';
            }
    }

//    /**
//     * Return an instance with the provided value replacing the specified header.
//     *
//     * While header names are case-insensitive, the casing of the header will
//     * be preserved by this function, and returned from getHeaders().
//     *
//     * This method MUST be implemented in such a way as to retain the
//     * immutability of the message, and MUST return an instance that has the
//     * new and/or updated header and value.
//     *
//     * @param string $name Case-insensitive header field name.
//     * @param string|string[] $value Header value(s).
//     * @return static
//     * @throws \InvalidArgumentException for invalid header names or values.
//     */
//    public function withHeader($name, $value)
//    {
//        // TODO: Implement withHeader() method.
//    }

//    /**
//     * Return an instance with the specified header appended with the given value.
//     *
//     * Existing values for the specified header will be maintained. The new
//     * value(s) will be appended to the existing list. If the header did not
//     * exist previously, it will be added.
//     *
//     * This method MUST be implemented in such a way as to retain the
//     * immutability of the message, and MUST return an instance that has the
//     * new header and/or value.
//     *
//     * @param string $name Case-insensitive header field name to add.
//     * @param string|string[] $value Header value(s).
//     * @return static
//     * @throws \InvalidArgumentException for invalid header names or values.
//     */
//    public function withAddedHeader($name, $value)
//    {
//        // TODO: Implement withAddedHeader() method.
//    }

//    /**
//     * Return an instance without the specified header.
//     *
//     * Header resolution MUST be done without case-sensitivity.
//     *
//     * This method MUST be implemented in such a way as to retain the
//     * immutability of the message, and MUST return an instance that removes
//     * the named header.
//     *
//     * @param string $name Case-insensitive header field name to remove.
//     * @return static
//     */
//    public function withoutHeader($name)
//    {
//        // TODO: Implement withoutHeader() method.
//    }

    public function getCookieCollection(): Collection
    {
        return $this->cookies;
    }

    public function getCookies(): array
    {
        return $this->cookies->toArray();
    }

    public function getCookieLine($name): string
    {
        if ($this->cookies->keyExists($name)) {
            return $this->cookies->get($name);
        }
        else {
            return '';
        }
    }

//    /**
//     * Gets the body of the message.
//     *
//     * @return StreamInterface Returns the body as a stream.
//     */
//    public function getBody()
//    {
//        // TODO: Implement getBody() method.
//    }

    /**
//     * Return an instance with the specified message body.
//     *
//     * The body MUST be a StreamInterface object.
//     *
//     * This method MUST be implemented in such a way as to retain the
//     * immutability of the message, and MUST return a new instance that has the
//     * new body stream.
//     *
//     * @param StreamInterface $body Body.
//     * @return static
//     * @throws \InvalidArgumentException When the body is not valid.
//     */
//    public function withBody(StreamInterface $body)
//    {
//        // TODO: Implement withBody() method.
//    }

    /**
     * Gets the response status code.
     *
     * The status code is a 3-digit integer result code of the server's attempt
     * to understand and satisfy the request.
     *
     * @return int Status code.
     */
    public function getStatusCode(): int
    {
        return (int)$this->statusCode;
    }

    public function setStatusCode(int $code, $reasonPhrase = null): Response
    {
        if ($code === null) {
            $code = 200;
        }

        $this->statusCode = (int)$code;

        if (! $this->hasValidStatusCode()) {
            throw new InvalidArgumentException('The HTTP status code is invalid: ' . $code);
        }

        if ($reasonPhrase === null) {
            $this->statusText = isset(static::$httpStatusCodes[$this->statusCode]) ? static::$httpStatusCodes[$this->statusCode] : '';
        }
            else {
                $this->statusText = $reasonPhrase;
            }

        return $this;
    }

    public function setStatusCodeByException($exception): Response
    {
        if ($exception instanceof \HttpException) {
            $this->setStatusCode($exception->statusCode);
        }
            else {
                $this->statusCode = 500;
            }

        return $this;
    }

    public function hasValidStatusCode(): bool
    {

        return ! (bool)($this->getStatusCode() < 100 || $this->getStatusCode() >= 600);
    }

    /**
     * Sets new data in the data array, clearing any previously set data.
     * @param array $data
     */
    public function setData($data = []): void
    {
        $this->data = $data;
    }

    /**
     * Preservers the previously added data and adds new data to the data array.
     * @param array $data
     */
    public function addData($data = []): void
    {
        $this->data = array_merge($this->data, $data);
    }

    /**
     * Clear the data array, removing any existing data previously set.
     */
    public function clearData(): void
    {
        $this->data = [];
    }

//    /**
//     * Return an instance with the specified status code and, optionally, reason phrase.
//     *
//     * If no reason phrase is specified, implementations MAY choose to default
//     * to the RFC 7231 or IANA recommended reason phrase for the response's
//     * status code.
//     *
//     * This method MUST be implemented in such a way as to retain the
//     * immutability of the message, and MUST return an instance that has the
//     * updated status and reason phrase.
//     *
//     * @link http://tools.ietf.org/html/rfc7231#section-6
//     * @link http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml
//     * @param int $code The 3-digit integer result code to set.
//     * @param string $reasonPhrase The reason phrase to use with the
//     *     provided status code; if none is provided, implementations MAY
//     *     use the defaults as suggested in the HTTP specification.
//     * @return static
//     * @throws \InvalidArgumentException For invalid status code arguments.
//     */
//    public function withStatus($code, $reasonPhrase = null): Response
//    {
//        // TODO: Implement withStatus() method.
//    }

    /**
     * Gets the response reason phrase associated with the status code.
     *
     * Because a reason phrase is not a required element in a response
     * status line, the reason phrase value MAY be null. Implementations MAY
     * choose to return the default RFC 7231 recommended reason phrase (or those
     * listed in the IANA HTTP Status Code Registry) for the response's
     * status code.
     *
     * @link http://tools.ietf.org/html/rfc7231#section-6
     * @link http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml
     * @return string Reason phrase; must return an empty string if none present.
     */
    public function getReasonPhrase(): string
    {
        return $this->statusText;
    }

    /**
     * Prepares the response to be sent by applying formatter, setting headers, codes, etc.
     * @throws InvalidConfigException
     * @throws InvalidArgumentException
     */
    public function prepare(): void
    {
        // If response is 204/304 it shouldn't have content
        if (in_array($this->getStatusCode(), [204, 304])) {
            $this->content = '';
            $this->stream = null;
            return;
        }

        if ($this->stream !== null) {
            return;
        }

        // Check that we have a formatter for the desired format
        // Transform the data and save it to the response content
        if (isset($this->formatters[$this->format])) {
            $formatterObj = (array)$this->formatters[$this->format];
            $formatter = new $formatterObj['class']($formatterObj['options']);

//            echo '<pre>';
//            print_r($formatterObj['class']);
//            echo '</pre>';
//
//            $formatter = new $formatterObj['class'];
//
//            if ($formatter instanceof ResponseFormatterInterface) {
//                echo 'it\'s an instance bitch !';
//            }
//
//
//            exit;




            if ($formatter instanceof ResponseFormatterInterface) {
//                $formatterClass = new $formatter['class']();
//                $formatterClass->format($this);
                $formatter->format($this);
            }
                else {
                    throw new InvalidConfigException("The '{$this->format}' response formatter is invalid. It must implement the ResponseFormatterInterface.");
                }
        }
            else if ($this->format === ResponseFormat::RAW) {
                $this->content = $this->data;
            }
            else {
                throw new InvalidConfigException("Unsupported response format: {$this->format}");
            }

        // Check that the formatter transformed the data correctly; a string.
        if (is_array($this->content)) {
            throw new InvalidArgumentException('Response conent must not be an array');
        }
            elseif (is_object($this->content)) {
                if (method_exists($this->content, '__toString')) {
                    $this->content = $this->content->__toString();
                }
                    else {
                        throw new InvalidArgumentException('Response content must be a string or an object implementing __toString().');
                    }
            }
    }

    public function clear()
    {
        $this->headers = new Collection;
        $this->cookies = new Collection;
        $this->statusCode = 200;
        $this->statusText = 'OK';
        $this->data = [];
        $this->stream = null;
        $this->content = [];
        $this->isSent = false;
    }

    public function sendHeaders()
    {
        if (headers_sent($file, $line)) {
            throw new InvalidCallException('Trying to send headers where already send.');
        }



//        echo '<pre>';
//        print_r($this->getHeaders());
//        echo '</pre>';
//
//
//        exit;

        if ($this->headers->length() >= 1) {
            foreach ($this->getHeaders() as $name => $value) {



                $name = str_replace(' ', '-', ucwords(str_replace('-', ' ', $name)));
                // Set replace for first occurrence of header but false afterwards to allow multiple
                $replace = true;

//                echo $name . '<br />';
//                print_r($value);

                header("$name: $value", $replace);

//                foreach ($values as $value) {
//                    header("$name: $value", $replace);
//                    $replace = false;
//                }
            }
//            exit;
        }

        header("HTTP/{$this->protocolVersion} {$this->getStatusCode()} {$this->statusText}");
        $this->sendCookies();
    }

    public function sendCookies()
    {
        if ($this->cookies === null) {
            return;
        }

        // TODO: validate the cookie

        // Set the cookies here
        foreach ($this->getCookies() as $cookie) {
            $value = $cookie->value;
            // TODO: check if cookie has expired send validate it here

            // To avoid breaking changes in >php7.3 with setting cookies
            // https://stackoverflow.com/questions/39750906/php-setcookie-samesite-strict/46971326#46971326
            if (PHP_VERSION_ID > 70300) {
                setcookie($cookie->name, $value, [
                   'expires' => $cookie->expire,
                   'path' => $cookie->path,
                   'domain' => $cookie->domain,
                   'secure' => $cookie->secure,
                   'httpOnly' => $cookie->httpOnly,
                   'sameSite' => ! empty($cookie->sameSite) ? $cookie->sameSite : null,
                ]);
            }
                else {
                    $cookiePath = $cookie->path;
                    if (! is_null($cookie->sameSite)) {
                        $cookiePath .= '; samesite=' . $cookie->sameSite;
                    }
                    setcookie($cookie->name, $value, $cookie->expire, $cookiePath, $cookie->domain, $cookie->secure, $cookie->httpOnly);
                }
        }
    }

    public function sendContent()
    {
        if ($this->stream === null) {
            echo $this->content;
            return;
        }

        // TODO: handle sending and streaming files, downloads, etc.
    }

    public function send()
    {
        if ($this->isSent) {
            return;
        }

        $this->prepare();
        $this->sendHeaders();
        $this->sendContent();


//        echo '<pre>';
//        print_r($this);
//        echo '</pre>';



        $this->isSent = true;
    }

}




// TODO: don't forget to clear output buffer after sending resposne