<?php

// SPDX-FileCopyrightText: 2004-2023 Ryan Parman, Sam Sneddon, Ryan McCue
// SPDX-License-Identifier: BSD-3-Clause

declare(strict_types=1);

namespace SimplePie;

use SimplePie\HTTP\Response;

/**
 * Used for fetching remote files and reading local files
 *
 * Supports HTTP 1.0 via cURL or fsockopen, with spotty HTTP 1.1 support
 *
 * This class can be overloaded with {@see \SimplePie\SimplePie::set_file_class()}
 *
 * @todo Move to properly supporting RFC2616 (HTTP/1.1)
 */
class File implements Response
{
    /**
     * @var string The final URL after following all redirects
     * @deprecated Use `get_final_requested_uri()` method.
     */
    public $url;

    /**
     * @var ?string User agent to use in requests
     * @deprecated Set the user agent in constructor.
     */
    public $useragent;

    /** @var bool */
    public $success = true;

    /** @var array<string, non-empty-array<string>> Canonical representation of headers */
    private $parsed_headers = [];
    /** @var array<string, string> Last known value of $headers property (used to detect external modification) */
    private $last_headers = [];
    /**
     * @var array<string, string> Headers as string for BC
     * @deprecated Use `get_headers()` method.
     */
    public $headers = [];

    /**
     * @var ?string Body of the HTTP response
     * @deprecated Use `get_body_content()` method.
     */
    public $body;

    /**
     * @var int Status code of the HTTP response
     * @deprecated Use `get_status_code()` method.
     */
    public $status_code = 0;

    /** @var non-negative-int Number of redirect that were already performed during this request sequence. */
    public $redirects = 0;

    /** @var ?string */
    public $error;

    /**
     * @var int-mask-of<SimplePie::FILE_SOURCE_*> Bit mask representing the method used to fetch the file and whether it is a local file or remote file obtained over HTTP.
     * @deprecated Backend is implementation detail which you should not care about; to see if the file was retrieved over HTTP, check if `get_final_requested_uri()` with `Misc::is_remote_uri()`.
     */
    public $method = \SimplePie\SimplePie::FILE_SOURCE_NONE;

    /**
     * @var string The permanent URL or the resource (first URL after the prefix of (only) permanent redirects)
     * @deprecated Use `get_permanent_uri()` method.
     */
    public $permanent_url;
    /** @var bool Whether the permanent URL is still writeable (prefix of permanent redirects has not ended) */
    private $permanentUrlMutable = true;

    /**
     * @param string $url
     * @param int $timeout
     * @param int $redirects
     * @param ?array<string, string> $headers
     * @param ?string $useragent
     * @param bool $force_fsockopen
     * @param array<int, mixed> $curl_options Specify cURL options.
     *  Special case for HTTP redirect handling:
     *   * CURLOPT_FOLLOWLOCATION not set or null: SimplePie PHP handling of HTTP redirections with security checks (default, recommended).
     *   * CURLOPT_FOLLOWLOCATION = truthy: Native cURL handling of HTTP redirections _without_ SimplePie security checks;
     *   * CURLOPT_FOLLOWLOCATION = falsy: No HTTP redirections at all;
     */
    public function __construct(string $url, int $timeout = 10, int $redirects = 5, ?array $headers = null, ?string $useragent = null, bool $force_fsockopen = false, array $curl_options = [])
    {
        if (function_exists('idn_to_ascii')) {
            $parsed = \SimplePie\Misc::parse_url($url);
            if ($parsed['authority'] !== '' && !ctype_print($parsed['authority'])) {
                $authority = (string) \idn_to_ascii($parsed['authority'], \IDNA_NONTRANSITIONAL_TO_ASCII, \INTL_IDNA_VARIANT_UTS46);
                $url = \SimplePie\Misc::compress_parse_url($parsed['scheme'], $authority, $parsed['path'], $parsed['query'], null);
            }
        }
        $this->url = $url;
        if ($this->permanentUrlMutable) {
            $this->permanent_url = $url;
        }
        $this->useragent = $useragent;
        if (\SimplePie\Misc::is_remote_uri($url)) {
            if ($useragent === null) {
                $useragent = (string) ini_get('user_agent');
                $this->useragent = $useragent;
            }
            if (!is_array($headers)) {
                $headers = [];
            }
            if (!$force_fsockopen && function_exists('curl_exec')) {
                $resolve = false; // FreshRSS
                $proxy = is_string($curl_options[CURLOPT_PROXY] ?? null) ? $curl_options[CURLOPT_PROXY] : null; // FreshRSS
                $proxy_type = $curl_options[CURLOPT_PROXYTYPE] ?? CURLPROXY_HTTP; // FreshRSS
                if ($proxy == null) { // FreshRSS
                    $resolve = $this->get_curl_resolve_info($url);
                    if ($resolve === null) {
                        $this->error = 'URL is not allowed to be resolved: ' . \SimplePie\Misc::url_remove_credentials($url);
                        $this->success = false;
                        return;
                    } elseif ($resolve === false) {
                        $this->error = 'Failed to resolve domain: ' . \SimplePie\Misc::url_remove_credentials($url);
                        $this->success = false;
                        return;
                    }
                    if (!empty($resolve)) {
                        $curl_options[CURLOPT_RESOLVE] = $resolve; // Prevent DNS rebinding
                    }
                } else { // FreshRSS
                    defined('CURLPROXY_HTTPS') or define('CURLPROXY_HTTPS', 2); // Compatibility cURL 7.51
                    $proxy_scheme = null;
                    switch ($proxy_type) {
                        case CURLPROXY_HTTP:
                            $proxy_scheme = 'http';
                            break;
                        case CURLPROXY_HTTPS:
                            $proxy_scheme = 'https';
                            break;
                        case CURLPROXY_SOCKS4:
                            $proxy_scheme = 'socks4';
                            break;
                        case CURLPROXY_SOCKS4A:
                            $proxy_scheme = 'socks4a';
                            break;
                        case CURLPROXY_SOCKS5:
                            $proxy_scheme = 'socks5';
                            break;
                        case CURLPROXY_SOCKS5_HOSTNAME:
                            $proxy_scheme = 'socks5h';
                            break;
                    }
                    if ($proxy_scheme === null) {
                        $this->error = 'Unsupported proxy type';
                        $this->success = false;
                        return;
                    }
                    $proxy = preg_replace('#^.*://#i', '', $proxy); // Strip any scheme already present in CURLOPT_PROXY
                    $proxy_url = "$proxy_scheme://$proxy"; // CURLOPT_PROXY ($proxy) is formatted as user:pass@hostname:port, with the part before @ being optional
                    $resolve = $this->get_curl_resolve_info($proxy_url, true);
                    if ($resolve === null) {
                        $this->error = 'Failed to fetch this URL, because the proxy’s IP is not in the allowlist [' .
                            \SimplePie\Misc::url_remove_credentials($url) . '] [' .
                            \SimplePie\Misc::url_remove_credentials($proxy_url) . ']';
                        $this->success = false;
                        return;
                    } elseif ($resolve === false) {
                        $this->error = 'Failed to resolve proxy hostname: ' . \SimplePie\Misc::url_remove_credentials($proxy_url);
                        $this->success = false;
                        return;
                    }
                    $curl_options[CURLOPT_PROXY] = $resolve; // Translate from a hostname:port value to ip:port, in order to prevent DNS rebinding
                    if (defined('CURLOPT_PROXY_SSL_VERIFYHOST')) {
                        // Available as of PHP 7.3.0 and cURL 7.52.0
                        $curl_options[CURLOPT_PROXY_SSL_VERIFYHOST] = 0; // Skip verifying the hostname (a bit unsafe, but needed since there is no CURLOPT_RESOLVE equivalent for proxy hostnames)
                    }
                }
                $this->method = \SimplePie\SimplePie::FILE_SOURCE_REMOTE | \SimplePie\SimplePie::FILE_SOURCE_CURL;
                $fp = self::curlInit($url, $timeout, $headers, $useragent, $curl_options);
                $responseHeaders = '';
                curl_setopt($fp, CURLOPT_HEADERFUNCTION, function ($ch, string $header) use (&$responseHeaders) {
                    $responseHeaders .= $header;
                    return strlen($header);
                });
                $responseBody = curl_exec($fp);
                $responseHeaders .= "\r\n";
                if (curl_errno($fp) === CURLE_WRITE_ERROR || curl_errno($fp) === CURLE_BAD_CONTENT_ENCODING) {
                    $this->error = 'cURL error ' . curl_errno($fp) . ': ' . curl_error($fp); // FreshRSS
                    $this->on_http_response($responseBody === false ? false : $responseHeaders . $responseBody, $curl_options);
                    $this->error = null; // FreshRSS
                    if (\PHP_VERSION_ID < 80000) {
                        curl_close($fp);
                    }
                    $fp = self::curlInit($url, $timeout, $headers, $useragent, $curl_options, false);
                    $responseHeaders = '';
                    curl_setopt($fp, CURLOPT_HEADERFUNCTION, function ($ch, string $header) use (&$responseHeaders) {
                        $responseHeaders .= $header;
                        return strlen($header);
                    });
                    $responseBody = curl_exec($fp);
                    $responseHeaders .= "\r\n";
                }
                $this->status_code = curl_getinfo($fp, CURLINFO_HTTP_CODE);
                if (curl_errno($fp) !== CURLE_OK) {
                    $this->error = 'cURL error ' . curl_errno($fp) . ': ' . curl_error($fp);
                    $this->success = false;
                    $this->on_http_response($responseBody === false ? false : $responseHeaders . $responseBody, $curl_options);
                } else {
                    // For PHPStan: `curl_exec` returns `false` only on error so the `is_string` check will always pass.
                    \assert(is_string($responseBody));
                    if (curl_getinfo($fp, CURLINFO_HTTP_CONNECTCODE) !== 0) {
                        // TODO: Replace with `CURLOPT_SUPPRESS_CONNECT_HEADERS` once PHP 7.2 support is dropped.
                        $responseHeaders = \SimplePie\HTTP\Parser::prepareHeaders($responseHeaders);
                    }
                    $this->on_http_response($responseHeaders . $responseBody, $curl_options);
                    $parser = new \SimplePie\HTTP\Parser($responseHeaders, true);
                    if ($parser->parse()) {
                        $this->set_headers($parser->headers);
                        $this->body = $responseBody;
                        // Handling of HTTP redirections:
                        $followLocation = $curl_options[CURLOPT_FOLLOWLOCATION] ?? null;

                        if ($followLocation) {
                            // Native cURL handling of HTTP redirections
                            $finalUrl = curl_getinfo($fp, CURLINFO_EFFECTIVE_URL);
                            if (is_string($finalUrl) && $finalUrl !== '') {
                                $this->url = $finalUrl;
                            }
                        } elseif ($followLocation === null) {
                            // SimplePie PHP handling of HTTP redirections (default)
                            if ((in_array($this->status_code, [300, 301, 302, 303, 307]) || $this->status_code > 307 && $this->status_code < 400) &&
                                ($locationHeader = $this->get_header_line('location')) !== '' && ($this->redirects < $redirects || $redirects === -1)) { // FreshRSS: added infinite redirects for -1
                                $this->redirects++;
                                $location = \SimplePie\Misc::absolutize_url($locationHeader, $url);
                                if ($location === false || !\SimplePie\Misc::is_remote_uri($location)) {
                                    $this->status_code = 0;
                                    $this->error = "Invalid redirect location, trying to base “{$locationHeader}” onto “{$url}”";
                                    $this->success = false;
                                    return;
                                }

                                // FreshRSS: POST to GET on redirect
                                if (isset($curl_options[CURLOPT_POST]) && in_array($this->status_code, [301, 302, 303], true)) {	// Not for 307 and 308, which must not change the HTTP method
                                    unset($curl_options[CURLOPT_POST]);
                                    unset($curl_options[CURLOPT_POSTFIELDS]);
                                    if (is_array($curl_options[CURLOPT_HTTPHEADER] ?? null)) {
                                        $curl_options[CURLOPT_HTTPHEADER] = array_filter(
                                            $curl_options[CURLOPT_HTTPHEADER],
                                            function ($header) {
                                                return is_string($header) && substr(strtolower(trim($header)), 0, 13) !== 'content-type:';
                                            }
                                        );
                                    }
                                }
                                // FreshRSS: cross-origin authentication headers removal
                                if (($url_parts_from = parse_url(strtolower($url))) === false) {
                                    throw new \InvalidArgumentException('Malformed URL: ' . $url);
                                }
                                if (($url_parts_to = parse_url(strtolower($location))) === false) {
                                    $this->status_code = 0;
                                    $this->error = "Invalid redirect location: malformed URL “{$location}”";
                                    $this->success = false;
                                    return;
                                }
                                foreach ([&$url_parts_from, &$url_parts_to] as &$url_parts) {
                                    if (!isset($url_parts['port']) && isset($url_parts['scheme'])) {
                                        if ($url_parts['scheme'] === 'http') {
                                            $url_parts['port'] = 80;
                                        } elseif ($url_parts['scheme'] === 'https') {
                                            $url_parts['port'] = 443;
                                        }
                                    }
                                }
                                unset($url_parts);
                                $sameOriginRedirect =
                                    ($url_parts_from['scheme'] ?? '') === ($url_parts_to['scheme'] ?? '') &&
                                    ($url_parts_from['host'] ?? '') === ($url_parts_to['host'] ?? '') &&
                                    ($url_parts_from['port'] ?? '') === ($url_parts_to['port'] ?? '');
                                if (!$sameOriginRedirect) {
                                    unset($curl_options[CURLOPT_COOKIE]);
                                    unset($curl_options[CURLOPT_USERPWD]);
                                    if (is_array($curl_options[CURLOPT_HTTPHEADER] ?? null)) {
                                        $curl_options[CURLOPT_HTTPHEADER] = array_filter(
                                            $curl_options[CURLOPT_HTTPHEADER],
                                            function ($header) {
                                                return is_string($header) && !preg_match('/^(Cookie|Authorization)\s*:/i', $header);
                                            }
                                        );
                                    }
                                }

                                $this->permanentUrlMutable = $this->permanentUrlMutable && ($this->status_code == 301 || $this->status_code == 308);
                                $this->__construct($location, $timeout, $redirects, $headers, $useragent, $force_fsockopen, $curl_options);
                                return;
                            }
                            // } elseif ($followLocation == false) {
                            // No HTTP redirections at all
                        }
                    }
                    if (\PHP_VERSION_ID < 80000) {
                        curl_close($fp);
                    }
                }
            } else {
                $this->method = \SimplePie\SimplePie::FILE_SOURCE_REMOTE | \SimplePie\SimplePie::FILE_SOURCE_FSOCKOPEN;
                if (($url_parts = parse_url($url)) === false) {
                    throw new \InvalidArgumentException('Malformed URL: ' . $url);
                }
                if (!isset($url_parts['host'])) {
                    throw new \InvalidArgumentException('Missing hostname: ' . $url);
                }
                $socket_host = $url_parts['host'];
                if (isset($url_parts['scheme']) && strtolower($url_parts['scheme']) === 'https') {
                    $socket_host = 'ssl://' . $socket_host;
                    $url_parts['port'] = 443;
                }
                if (!isset($url_parts['port'])) {
                    $url_parts['port'] = 80;
                }
                $fp = @fsockopen($socket_host, $url_parts['port'], $errno, $errstr, $timeout);
                if (!$fp) {
                    $this->error = 'fsockopen error: ' . $errstr;
                    $this->success = false;
                    $this->on_http_response(false);
                } else {
                    stream_set_timeout($fp, $timeout);
                    if (isset($url_parts['path'])) {
                        if (isset($url_parts['query'])) {
                            $get = "$url_parts[path]?$url_parts[query]";
                        } else {
                            $get = $url_parts['path'];
                        }
                    } else {
                        $get = '/';
                    }
                    $out = "GET $get HTTP/1.1\r\n";
                    $out .= "Host: $url_parts[host]\r\n";
                    $out .= "User-Agent: $useragent\r\n";
                    if (extension_loaded('zlib')) {
                        $out .= "Accept-Encoding: x-gzip,gzip,deflate\r\n";
                    }

                    if (isset($url_parts['user']) && isset($url_parts['pass'])) {
                        $out .= "Authorization: Basic " . base64_encode("$url_parts[user]:$url_parts[pass]") . "\r\n";
                    }
                    foreach ($headers as $key => $value) {
                        $out .= "$key: $value\r\n";
                    }
                    $out .= "Connection: Close\r\n\r\n";
                    fwrite($fp, $out);

                    $info = stream_get_meta_data($fp);

                    $responseHeaders = '';
                    while (!$info['eof'] && !$info['timed_out']) {
                        $responseHeaders .= fread($fp, 1160);
                        $info = stream_get_meta_data($fp);
                    }
                    if (!$info['timed_out']) {
                        $parser = new \SimplePie\HTTP\Parser($responseHeaders, true);
                        if ($parser->parse()) {
                            $this->set_headers($parser->headers);
                            $this->body = $parser->body;
                            $this->status_code = $parser->status_code;
                            $this->on_http_response($responseHeaders);
                            if ((in_array($this->status_code, [300, 301, 302, 303, 307]) || $this->status_code > 307 && $this->status_code < 400) &&
                                ($locationHeader = $this->get_header_line('location')) !== '' && ($this->redirects < $redirects || $redirects === -1)) { // FreshRSS: added infinite redirects for -1
                                $this->redirects++;
                                $location = \SimplePie\Misc::absolutize_url($locationHeader, $url);
                                $this->permanentUrlMutable = $this->permanentUrlMutable && ($this->status_code == 301 || $this->status_code == 308);
                                if ($location === false || !\SimplePie\Misc::is_remote_uri($location)) {
                                    $this->status_code = 0;
                                    $this->error = "Invalid redirect location, trying to base “{$locationHeader}” onto “{$url}”";
                                    $this->success = false;
                                    return;
                                }

                                // FreshRSS: POST to GET on redirect is not applicable here as fsockopen only ever performs GET requests.
                                // FreshRSS: cross-origin authentication headers removal
                                if (($url_parts_from = parse_url(strtolower($url))) === false) {
                                    throw new \InvalidArgumentException('Malformed URL: ' . $url);
                                }
                                if (($url_parts_to = parse_url(strtolower($location))) === false) {
                                    $this->status_code = 0;
                                    $this->error = "Invalid redirect location: malformed URL “{$location}”";
                                    $this->success = false;
                                    return;
                                }
                                foreach ([&$url_parts_from, &$url_parts_to] as &$url_parts) {
                                    if (!isset($url_parts['port']) && isset($url_parts['scheme'])) {
                                        if ($url_parts['scheme'] === 'http') {
                                            $url_parts['port'] = 80;
                                        } elseif ($url_parts['scheme'] === 'https') {
                                            $url_parts['port'] = 443;
                                        }
                                    }
                                }
                                unset($url_parts);
                                $sameOriginRedirect =
                                    ($url_parts_from['scheme'] ?? '') === ($url_parts_to['scheme'] ?? '') &&
                                    ($url_parts_from['host'] ?? '') === ($url_parts_to['host'] ?? '') &&
                                    ($url_parts_from['port'] ?? '') === ($url_parts_to['port'] ?? '');
                                if (!$sameOriginRedirect) {
                                    $headers = array_filter(
                                        $headers,
                                        function (string $key) {
                                            return !preg_match('/^(Cookie|Authorization)$/i', $key);
                                        },
                                        ARRAY_FILTER_USE_KEY
                                    );
                                }

                                $this->__construct($location, $timeout, $redirects, $headers, $useragent, $force_fsockopen, $curl_options);
                                return;
                            }
                            if (($contentEncodingHeader = $this->get_header_line('content-encoding')) !== '') {
                                assert($this->body !== null); // For PHPStan // FreshRSS
                                // Hey, we act dumb elsewhere, so let's do that here too
                                switch (strtolower(trim($contentEncodingHeader, "\x09\x0A\x0D\x20"))) {
                                    case 'gzip':
                                    case 'x-gzip':
                                        if (($decompressed = gzdecode($this->body)) === false) {
                                            $this->error = 'Unable to decode HTTP "gzip" stream';
                                            $this->success = false;
                                        } else {
                                            $this->body = $decompressed;
                                        }
                                        break;

                                    case 'deflate':
                                        if (($decompressed = gzinflate($this->body)) !== false) {
                                            $this->body = $decompressed;
                                        } elseif (($decompressed = gzuncompress($this->body)) !== false) {
                                            $this->body = $decompressed;
                                        } elseif (($decompressed = gzdecode($this->body)) !== false) {
                                            $this->body = $decompressed;
                                        } else {
                                            $this->error = 'Unable to decode HTTP "deflate" stream';
                                            $this->success = false;
                                        }
                                        break;

                                    default:
                                        $this->error = 'Unknown content coding';
                                        $this->success = false;
                                }
                            }
                        } else {
                            $this->error = 'Could not parse'; // FreshRSS
                            $this->success = false; // FreshRSS
                            $this->on_http_response($responseHeaders);
                        }
                    } else {
                        $this->error = 'fsocket timed out';
                        $this->success = false;
                        $this->on_http_response($responseHeaders);
                    }
                    fclose($fp);
                }
            }
        } else {
            $this->method = \SimplePie\SimplePie::FILE_SOURCE_LOCAL | \SimplePie\SimplePie::FILE_SOURCE_FILE_GET_CONTENTS;
            $filebody = false;
            if (empty($url) || !is_readable($url) || false === ($filebody = file_get_contents($url))) {
                $this->body = '';
                $this->error = sprintf('file "%s" is not readable', $url);
                $this->success = false;
            } else {
                $this->body = $filebody;
                $this->status_code = 200;
            }
            $this->on_http_response($filebody);
        }
        if ($this->success) {
            assert($this->body !== null); // For PHPStan
            // Leading whitespace may cause XML parsing errors (XML declaration cannot be preceded by anything other than BOM) so we trim it.
            // Note that unlike built-in `trim` function’s default settings, we do not trim `\x00` to avoid breaking characters in UTF-16 or UTF-32 encoded strings.
            // We also only do that when the whitespace is followed by `<`, so that we do not break e.g. UTF-16LE encoded whitespace like `\n\x00` in half.
            $this->body = preg_replace('/^[ \n\r\t\v]+</', '<', $this->body);
        }
    }

    /**
     * Event to allow inheriting classes to e.g. log the HTTP responses.
     * Triggered just after an HTTP response is received.
     * @param string|false $response The raw HTTP response headers and body, or false in case of failure (as returned by curl_exec()).
     * FreshRSS.
     * @param array<int, mixed> $curl_options
     */
    protected function on_http_response($response, array $curl_options = []): void
    {
    }

    /**
     * Event to allow inheriting classes to control fetching certain URLs.
     * @param string $url
     * @return array<string>|string|null|false Returns a value for CURLOPT_RESOLVE as an array, null if no allowed IPs were found, false if the domain failed to resolve. Can also be used for checking if the CURLOPT_PROXY value is allowed, by providing a proxy URL with the `for_proxy` parameter set to `true`. In that case, a string value will be returned with the hostname resolved to an IP if allowed.
     */
    protected function get_curl_resolve_info(string $url, bool $for_proxy = false)
    {
        if ($for_proxy) {
            $pos = strpos($url, '://');
            if ($pos === false) {
                return false;
            }
            return substr($url, $pos + 3);
        }
        return [];
    }

    public function get_permanent_uri(): string
    {
        return (string) $this->permanent_url;
    }

    public function get_final_requested_uri(): string
    {
        return (string) $this->url;
    }

    public function get_status_code(): int
    {
        return (int) $this->status_code;
    }

    public function set_status_code(int $status_code): void
    {
        $this->status_code = $status_code;
    }

    public function get_headers(): array
    {
        $this->maybe_update_headers();
        return $this->parsed_headers;
    }

    public function has_header(string $name): bool
    {
        $this->maybe_update_headers();
        return $this->get_header($name) !== [];
    }

    public function get_header(string $name): array
    {
        $this->maybe_update_headers();
        return $this->parsed_headers[strtolower($name)] ?? [];
    }

    public function with_header(string $name, $value)
    {
        $this->maybe_update_headers();
        $new = clone $this;

        $newHeader = [
            strtolower($name) => (array) $value,
        ];
        $new->set_headers($newHeader + $this->get_headers());

        return $new;
    }

    public function get_header_line(string $name): string
    {
        $this->maybe_update_headers();
        return implode(', ', $this->get_header($name));
    }

    public function get_body_content(): string
    {
        return (string) $this->body;
    }

    public function set_body_content(string $body): void
    {
        $this->body = $body;
    }

    /**
     * Check if the $headers property was changed and update the internal state accordingly.
     */
    private function maybe_update_headers(): void
    {
        if ($this->headers !== $this->last_headers) {
            $this->parsed_headers = array_map(
                function (string $header_line): array {
                    if (strpos($header_line, ',') === false) {
                        return [$header_line];
                    } else {
                        return array_map('trim', explode(',', $header_line));
                    }
                },
                $this->headers
            );
        }
        $this->last_headers = $this->headers;
    }

    /**
     * Sets headers internally.
     *
     * @param array<string, non-empty-array<string>> $headers
     */
    private function set_headers(array $headers): void
    {
        $this->parsed_headers = $headers;
        $this->headers = self::flatten_headers($headers);
        $this->last_headers = $this->headers;
    }

    /**
     * Converts PSR-7 compatible headers into a legacy format.
     *
     * @param array<string, non-empty-array<string>> $headers
     *
     * @return array<string, string>
     */
    private function flatten_headers(array $headers): array
    {
        return array_map(function (array $values): string {
            return implode(',', $values);
        }, $headers);
    }

    /**
     * Create a File instance from another Response
     *
     * For BC reasons in some places there MUST be a `File` instance
     * instead of a `Response` implementation
     *
     * @see Locator::__construct()
     * @internal
     */
    final public static function fromResponse(Response $response): self
    {
        if ($response instanceof self) {
            return $response;
        }

        /** @var File */
        $file = (new \ReflectionClass(File::class))->newInstanceWithoutConstructor();

        $file->url = $response->get_final_requested_uri();
        $file->useragent = null;
        $file->set_headers($response->get_headers());
        $file->body = $response->get_body_content();
        $file->status_code = $response->get_status_code();
        $file->permanent_url = $response->get_permanent_uri();

        return $file;
    }

    /**
     * @param array<string, string> $headers
     * @param array<int, mixed> $curl_options
     * @return \CurlHandle
     */
    private static function curlInit(
        string $url,
        int $timeout,
        array $headers,
        string $useragent,
        array $curl_options,
        bool $setAcceptEncoding = true
    ) {
        $fp = curl_init();

        $headers2 = [];
        foreach ($headers as $key => $value) {
            $headers2[] = "$key: $value";
        }
        if (isset($curl_options[CURLOPT_HTTPHEADER])) {
            if (is_array($curl_options[CURLOPT_HTTPHEADER])) {
                $headers2 = array_merge($headers2, $curl_options[CURLOPT_HTTPHEADER]);
            }
            unset($curl_options[CURLOPT_HTTPHEADER]);
        }
        if ($setAcceptEncoding) {
            if (version_compare(\SimplePie\Misc::get_curl_version(), '7.21.6', '>=')) {
                curl_setopt($fp, CURLOPT_ACCEPT_ENCODING, '');
            } else {
                curl_setopt($fp, CURLOPT_ENCODING, '');
            }
        }
        /** @var non-empty-string $url */
        curl_setopt($fp, CURLOPT_URL, $url);
        curl_setopt($fp, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($fp, CURLOPT_FAILONERROR, true);
        curl_setopt($fp, CURLOPT_TIMEOUT, $timeout);
        curl_setopt($fp, CURLOPT_CONNECTTIMEOUT, $timeout);
        // curl_setopt($fp, CURLOPT_REFERER, \SimplePie\Misc::url_remove_credentials($url)); // FreshRSS removed
        curl_setopt($fp, CURLOPT_USERAGENT, $useragent);
        curl_setopt($fp, CURLOPT_HTTPHEADER, $headers2);
        foreach ($curl_options as $curl_param => $curl_value) {
            curl_setopt($fp, $curl_param, $curl_value);
        }

        return $fp;
    }
}

class_alias('SimplePie\File', 'SimplePie_File');
