<?php
/**
 * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
 * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
 *
 * Licensed under The MIT License
 * Redistributions of files must retain the above copyright notice.
 *
 * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
 * @link          https://cakephp.org CakePHP(tm) Project
 * @since         3.0.0
 * @license       https://opensource.org/licenses/mit-license.php MIT License
 */
namespace Cake\Http\Client;

// This alias is necessary to avoid class name conflicts
// with the deprecated class in this namespace.
use Cake\Http\Cookie\CookieCollection as CookiesCollection;
use Cake\Http\Cookie\CookieInterface;
use Laminas\Diactoros\MessageTrait;
use Laminas\Diactoros\Stream;
use Psr\Http\Message\ResponseInterface;
use RuntimeException;

/**
 * Implements methods for HTTP responses.
 *
 * All of the following examples assume that `$response` is an
 * instance of this class.
 *
 * ### Get header values
 *
 * Header names are case-insensitive, but normalized to Title-Case
 * when the response is parsed.
 *
 * ```
 * $val = $response->getHeaderLine('content-type');
 * ```
 *
 * Will read the Content-Type header. You can get all set
 * headers using:
 *
 * ```
 * $response->getHeaders();
 * ```
 *
 * ### Get the response body
 *
 * You can access the response body stream using:
 *
 * ```
 * $content = $response->getBody();
 * ```
 *
 * You can get the body string using:
 *
 * ```
 * $content = $response->getStringBody();
 * ```
 *
 * If your response body is in XML or JSON you can use
 * special content type specific accessors to read the decoded data.
 * JSON data will be returned as arrays, while XML data will be returned
 * as SimpleXML nodes:
 *
 * ```
 * // Get as xml
 * $content = $response->getXml()
 * // Get as json
 * $content = $response->getJson()
 * ```
 *
 * If the response cannot be decoded, null will be returned.
 *
 * ### Check the status code
 *
 * You can access the response status code using:
 *
 * ```
 * $content = $response->getStatusCode();
 * ```
 */
class Response extends Message implements ResponseInterface
{
    use MessageTrait;

    /**
     * The status code of the response.
     *
     * @var int
     */
    protected $code;

    /**
     * Cookie Collection instance
     *
     * @var \Cake\Http\Cookie\CookieCollection
     */
    protected $cookies;

    /**
     * The reason phrase for the status code
     *
     * @var string
     */
    protected $reasonPhrase;

    /**
     * Cached decoded XML data.
     *
     * @var \SimpleXMLElement
     */
    protected $_xml;

    /**
     * Cached decoded JSON data.
     *
     * @var array
     */
    protected $_json;

    /**
     * Map of public => property names for __get()
     *
     * @var array
     */
    protected $_exposedProperties = [
        'cookies' => '_getCookies',
        'body' => '_getBody',
        'code' => 'code',
        'json' => '_getJson',
        'xml' => '_getXml',
        'headers' => '_getHeaders',
    ];

    /**
     * Map of deprecated magic properties.
     *
     * @var array
     * @internal
     */
    protected $_deprecatedMagicProperties = [
        'cookies' => 'getCookies()',
        'body' => 'getStringBody()',
        'json' => 'getJson()',
        'xml' => 'getXml()',
        'headers' => 'getHeaders()',
    ];

    /**
     * Constructor
     *
     * @param array $headers Unparsed headers.
     * @param string $body The response body.
     */
    public function __construct($headers = [], $body = '')
    {
        $this->_parseHeaders($headers);
        if ($this->getHeaderLine('Content-Encoding') === 'gzip') {
            $body = $this->_decodeGzipBody($body);
        }
        $stream = new Stream('php://memory', 'wb+');
        $stream->write($body);
        $stream->rewind();
        $this->stream = $stream;
    }

    /**
     * Uncompress a gzip response.
     *
     * Looks for gzip signatures, and if gzinflate() exists,
     * the body will be decompressed.
     *
     * @param string $body Gzip encoded body.
     * @return string
     * @throws \RuntimeException When attempting to decode gzip content without gzinflate.
     */
    protected function _decodeGzipBody($body)
    {
        if (!function_exists('gzinflate')) {
            throw new RuntimeException('Cannot decompress gzip response body without gzinflate()');
        }
        $offset = 0;
        // Look for gzip 'signature'
        if (substr($body, 0, 2) === "\x1f\x8b") {
            $offset = 2;
        }
        // Check the format byte
        if (substr($body, $offset, 1) === "\x08") {
            return gzinflate(substr($body, $offset + 8));
        }
    }

    /**
     * Parses headers if necessary.
     *
     * - Decodes the status code and reasonphrase.
     * - Parses and normalizes header names + values.
     *
     * @param array $headers Headers to parse.
     * @return void
     */
    protected function _parseHeaders($headers)
    {
        foreach ($headers as $key => $value) {
            if (substr($value, 0, 5) === 'HTTP/') {
                preg_match('/HTTP\/([\d.]+) ([0-9]+)(.*)/i', $value, $matches);
                $this->protocol = $matches[1];
                $this->code = (int)$matches[2];
                $this->reasonPhrase = trim($matches[3]);
                continue;
            }
            if (strpos($value, ':') === false) {
                continue;
            }
            list($name, $value) = explode(':', $value, 2);
            $value = trim($value);
            $name = trim($name);

            $normalized = strtolower($name);

            if (isset($this->headers[$name])) {
                $this->headers[$name][] = $value;
            } else {
                $this->headers[$name] = (array)$value;
                $this->headerNames[$normalized] = $name;
            }
        }
    }

    /**
     * Check if the response was OK
     *
     * @return bool
     */
    public function isOk()
    {
        $codes = [
            static::STATUS_OK,
            static::STATUS_CREATED,
            static::STATUS_ACCEPTED,
            static::STATUS_NON_AUTHORITATIVE_INFORMATION,
            static::STATUS_NO_CONTENT,
        ];

        return in_array($this->code, $codes);
    }

    /**
     * Check if the response status code was in the 2xx range
     *
     * @return bool
     */
    public function isSuccess()
    {
        return $this->code >= 200 && $this->code <= 299;
    }

    /**
     * Check if the response had a redirect status code.
     *
     * @return bool
     */
    public function isRedirect()
    {
        $codes = [
            static::STATUS_MOVED_PERMANENTLY,
            static::STATUS_FOUND,
            static::STATUS_SEE_OTHER,
            static::STATUS_TEMPORARY_REDIRECT,
        ];

        return (
            in_array($this->code, $codes) &&
            $this->getHeaderLine('Location')
        );
    }

    /**
     * Get the status code from the response
     *
     * @return int
     * @deprecated 3.3.0 Use getStatusCode() instead.
     */
    public function statusCode()
    {
        deprecationWarning(
            'Response::statusCode() is deprecated. ' .
            'Use Response::getStatusCode() instead.'
        );

        return $this->code;
    }

    /**
     * {@inheritdoc}
     *
     * @return int The status code.
     */
    public function getStatusCode()
    {
        return $this->code;
    }

    /**
     * {@inheritdoc}
     *
     * @param int $code The status code to set.
     * @param string $reasonPhrase The status reason phrase.
     * @return $this A copy of the current object with an updated status code.
     */
    public function withStatus($code, $reasonPhrase = '')
    {
        $new = clone $this;
        $new->code = $code;
        $new->reasonPhrase = $reasonPhrase;

        return $new;
    }

    /**
     * {@inheritdoc}
     *
     * @return string The current reason phrase.
     */
    public function getReasonPhrase()
    {
        return $this->reasonPhrase;
    }

    /**
     * Get the encoding if it was set.
     *
     * @return string|null
     * @deprecated 3.3.0 Use getEncoding() instead.
     */
    public function encoding()
    {
        deprecationWarning(
            'Response::encoding() is deprecated. ' .
            'Use Response::getEncoding() instead.'
        );

        return $this->getEncoding();
    }

    /**
     * Get the encoding if it was set.
     *
     * @return string|null
     */
    public function getEncoding()
    {
        $content = $this->getHeaderLine('content-type');
        if (!$content) {
            return null;
        }
        preg_match('/charset\s?=\s?[\'"]?([a-z0-9-_]+)[\'"]?/i', $content, $matches);
        if (empty($matches[1])) {
            return null;
        }

        return $matches[1];
    }

    /**
     * Read single/multiple header value(s) out.
     *
     * @param string|null $name The name of the header you want. Leave
     *   null to get all headers.
     * @return mixed Null when the header doesn't exist. An array
     *   will be returned when getting all headers or when getting
     *   a header that had multiple values set. Otherwise a string
     *   will be returned.
     * @deprecated 3.3.0 Use getHeader() and getHeaderLine() instead.
     */
    public function header($name = null)
    {
        deprecationWarning(
            'Response::header() is deprecated. ' .
            'Use Response::getHeader() and getHeaderLine() instead.'
        );

        if ($name === null) {
            return $this->_getHeaders();
        }
        $header = $this->getHeader($name);
        if (count($header) === 1) {
            return $header[0];
        }

        return $header;
    }

    /**
     * Read single/multiple cookie values out.
     *
     * *Note* This method will only provide access to cookies that
     * were added as part of the constructor. If cookies are added post
     * construction they will not be accessible via this method.
     *
     * @param string|null $name The name of the cookie you want. Leave
     *   null to get all cookies.
     * @param bool $all Get all parts of the cookie. When false only
     *   the value will be returned.
     * @return mixed
     * @deprecated 3.3.0 Use getCookie(), getCookieData() or getCookies() instead.
     */
    public function cookie($name = null, $all = false)
    {
        deprecationWarning(
            'Response::cookie() is deprecated. ' .
            'Use Response::getCookie(), getCookieData() or getCookies() instead.'
        );

        if ($name === null) {
            return $this->getCookies();
        }
        if ($all) {
            return $this->getCookieData($name);
        }

        return $this->getCookie($name);
    }

    /**
     * Get the all cookie data.
     *
     * @return array The cookie data
     */
    public function getCookies()
    {
        return $this->_getCookies();
    }

    /**
     * Get the cookie collection from this response.
     *
     * This method exposes the response's CookieCollection
     * instance allowing you to interact with cookie objects directly.
     *
     * @return \Cake\Http\Cookie\CookieCollection
     */
    public function getCookieCollection()
    {
        $this->buildCookieCollection();

        return $this->cookies;
    }

    /**
     * Get the value of a single cookie.
     *
     * @param string $name The name of the cookie value.
     * @return string|array|null Either the cookie's value or null when the cookie is undefined.
     */
    public function getCookie($name)
    {
        $this->buildCookieCollection();
        if (!$this->cookies->has($name)) {
            return null;
        }

        return $this->cookies->get($name)->getValue();
    }

    /**
     * Get the full data for a single cookie.
     *
     * @param string $name The name of the cookie value.
     * @return array|null Either the cookie's data or null when the cookie is undefined.
     */
    public function getCookieData($name)
    {
        $this->buildCookieCollection();

        if (!$this->cookies->has($name)) {
            return null;
        }

        $cookie = $this->cookies->get($name);

        return $this->convertCookieToArray($cookie);
    }

    /**
     * Convert the cookie into an array of its properties.
     *
     * This method is compatible with older client code that
     * expects date strings instead of timestamps.
     *
     * @param \Cake\Http\Cookie\CookieInterface $cookie Cookie object.
     * @return array
     */
    protected function convertCookieToArray(CookieInterface $cookie)
    {
        return [
            'name' => $cookie->getName(),
            'value' => $cookie->getValue(),
            'path' => $cookie->getPath(),
            'domain' => $cookie->getDomain(),
            'secure' => $cookie->isSecure(),
            'httponly' => $cookie->isHttpOnly(),
            'expires' => $cookie->getFormattedExpires(),
        ];
    }

    /**
     * Lazily build the CookieCollection and cookie objects from the response header
     *
     * @return void
     */
    protected function buildCookieCollection()
    {
        if ($this->cookies) {
            return;
        }
        $this->cookies = CookiesCollection::createFromHeader($this->getHeader('Set-Cookie'));
    }

    /**
     * Property accessor for `$this->cookies`
     *
     * @return array Array of Cookie data.
     */
    protected function _getCookies()
    {
        $this->buildCookieCollection();

        $cookies = [];
        foreach ($this->cookies as $cookie) {
            $cookies[$cookie->getName()] = $this->convertCookieToArray($cookie);
        }

        return $cookies;
    }

    /**
     * Get the HTTP version used.
     *
     * @return string
     * @deprecated 3.3.0 Use getProtocolVersion()
     */
    public function version()
    {
        deprecationWarning(
            'Response::version() is deprecated. ' .
            'Use Response::getProtocolVersion() instead.'
        );

        return $this->protocol;
    }

    /**
     * Get the response body.
     *
     * By passing in a $parser callable, you can get the decoded
     * response content back.
     *
     * For example to get the json data as an object:
     *
     * ```
     * $body = $response->getJson();
     * ```
     *
     * @param callable|null $parser The callback to use to decode
     *   the response body.
     * @return mixed The response body.
     * @deprecated 3.7.0 Use getStringBody()/getJson()/getXml() instead.
     */
    public function body($parser = null)
    {
        deprecationWarning(
            'Response::body() is deprecated. Use getStringBody()/getJson()/getXml() instead.'
        );

        $stream = $this->stream;
        $stream->rewind();
        if ($parser) {
            return $parser($stream->getContents());
        }

        return $stream->getContents();
    }

    /**
     * Get the response body as string.
     *
     * @return string
     */
    public function getStringBody()
    {
        return $this->_getBody();
    }

    /**
     * Get the response body as JSON decoded data.
     *
     * @return array|null
     */
    public function getJson()
    {
        return $this->_getJson();
    }

    /**
     * Get the response body as JSON decoded data.
     *
     * @return array|null
     */
    protected function _getJson()
    {
        if ($this->_json) {
            return $this->_json;
        }

        return $this->_json = json_decode($this->_getBody(), true);
    }

    /**
     * Get the response body as XML decoded data.
     *
     * @return \SimpleXMLElement|null
     */
    public function getXml()
    {
        return $this->_getXml();
    }

    /**
     * Get the response body as XML decoded data.
     *
     * @return \SimpleXMLElement|null
     */
    protected function _getXml()
    {
        if ($this->_xml) {
            return $this->_xml;
        }
        libxml_use_internal_errors();
        $data = simplexml_load_string($this->_getBody());
        if ($data) {
            $this->_xml = $data;

            return $this->_xml;
        }

        return null;
    }

    /**
     * Provides magic __get() support.
     *
     * @return array
     */
    protected function _getHeaders()
    {
        $out = [];
        foreach ($this->headers as $key => $values) {
            $out[$key] = implode(',', $values);
        }

        return $out;
    }

    /**
     * Provides magic __get() support.
     *
     * @return string
     */
    protected function _getBody()
    {
        $this->stream->rewind();

        return $this->stream->getContents();
    }

    /**
     * Read values as properties.
     *
     * @param string $name Property name.
     * @return mixed
     */
    public function __get($name)
    {
        if (!isset($this->_exposedProperties[$name])) {
            return false;
        }
        $key = $this->_exposedProperties[$name];
        if (substr($key, 0, 4) === '_get') {
            deprecationWarning(sprintf(
                'Response::%s is deprecated. Use Response::%s instead.',
                $name,
                $this->_deprecatedMagicProperties[$name]
            ));

            return $this->{$key}();
        }

        if ($key === 'code') {
            deprecationWarning(
                'Response::code() is deprecated. ' .
                'Use Response::getStatusCode() instead.'
            );
        }

        return $this->{$key};
    }

    /**
     * isset/empty test with -> syntax.
     *
     * @param string $name Property name.
     * @return bool
     */
    public function __isset($name)
    {
        if (!isset($this->_exposedProperties[$name])) {
            return false;
        }
        $key = $this->_exposedProperties[$name];
        if (substr($key, 0, 4) === '_get') {
            deprecationWarning(sprintf(
                'Response::%s is deprecated. Use Response::%s instead.',
                $name,
                $this->_deprecatedMagicProperties[$name]
            ));

            $val = $this->{$key}();

            return $val !== null;
        }

        if ($key === 'code') {
            deprecationWarning(
                'Response::code() is deprecated. ' .
                'Use Response::getStatusCode() instead.'
            );
        }

        return isset($this->{$key});
    }
}

// @deprecated 3.4.0 Add backwards compat alias.
class_alias('Cake\Http\Client\Response', 'Cake\Network\Http\Response');
