<?php

namespace SilverStripe\Control;

use SilverStripe\Assets\File;
use SilverStripe\Core\Config\Configurable;
use SilverStripe\Core\Convert;
use InvalidArgumentException;
use finfo;

/**
 * A class with HTTP-related helpers. Like Debug, this is more a bundle of methods than a class.
 */
class HTTP
{
    use Configurable;

    /**
     * @var int
     */
    protected static $cache_age = 0;

    /**
     * @var int
     */
    protected static $modification_date = null;

    /**
     * @var string
     */
    protected static $etag = null;

    /**
     * @config
     *
     * @var bool
     */
    private static $cache_ajax_requests = true;

    /**
     * Turns a local system filename into a URL by comparing it to the script filename.
     *
     * @param string $filename
     * @return string
     */
    public static function filename2url($filename)
    {
        $filename = realpath($filename);
        if (!$filename) {
            return null;
        }

        // Filter files outside of the webroot
        $base = realpath(BASE_PATH);
        $baseLength = strlen($base);
        if (substr($filename, 0, $baseLength) !== $base) {
            return null;
        }

        $relativePath = ltrim(substr($filename, $baseLength), '/\\');
        return Director::absoluteURL($relativePath);
    }

    /**
     * Turn all relative URLs in the content to absolute URLs.
     *
     * @param string $html
     *
     * @return string
     */
    public static function absoluteURLs($html)
    {
        $html = str_replace('$CurrentPageURL', Controller::curr()->getRequest()->getURL(), $html);
        return HTTP::urlRewriter($html, function ($url) {
            //no need to rewrite, if uri has a protocol (determined here by existence of reserved URI character ":")
            if (preg_match('/^\w+:/', $url)) {
                return $url;
            }
            return Director::absoluteURL($url, true);
        });
    }

    /**
     * Rewrite all the URLs in the given content, evaluating the given string as PHP code.
     *
     * Put $URL where you want the URL to appear, however, you can't embed $URL in strings, for example:
     * <ul>
     * <li><code>'"../../" . $URL'</code></li>
     * <li><code>'myRewriter($URL)'</code></li>
     * <li><code>'(substr($URL, 0, 1)=="/") ? "../" . substr($URL, 1) : $URL'</code></li>
     * </ul>
     *
     * As of 3.2 $code should be a callable which takes a single parameter and returns the rewritten,
     * for example:
     * <code>
     * function($url) {
     *      return Director::absoluteURL($url, true);
     * }
     * </code>
     *
     * @param string $content The HTML to search for links to rewrite.
     * @param callable $code Either a string that can evaluate to an expression to rewrite links
     * (depreciated), or a callable that takes a single parameter and returns the rewritten URL.
     *
     * @return string The content with all links rewritten as per the logic specified in $code.
     */
    public static function urlRewriter($content, $code)
    {
        if (!is_callable($code)) {
            throw new InvalidArgumentException(
                'HTTP::urlRewriter expects a callable as the second parameter'
            );
        }

        // Replace attributes
        $attribs = array("src", "background", "a" => "href", "link" => "href", "base" => "href");
        $regExps = [];
        foreach ($attribs as $tag => $attrib) {
            if (!is_numeric($tag)) {
                $tagPrefix = "$tag ";
            } else {
                $tagPrefix = "";
            }

            $regExps[] = "/(<{$tagPrefix}[^>]*$attrib *= *\")([^\"]*)(\")/i";
            $regExps[] = "/(<{$tagPrefix}[^>]*$attrib *= *')([^']*)(')/i";
            $regExps[] = "/(<{$tagPrefix}[^>]*$attrib *= *)([^\"' ]*)( )/i";
        }
        // Replace css styles
        // @todo - http://www.css3.info/preview/multiple-backgrounds/
        $styles = array('background-image', 'background', 'list-style-image', 'list-style', 'content');
        foreach ($styles as $style) {
            $regExps[] = "/($style:[^;]*url *\\(\")([^\"]+)(\"\\))/i";
            $regExps[] = "/($style:[^;]*url *\\(')([^']+)('\\))/i";
            $regExps[] = "/($style:[^;]*url *\\()([^\"\\)')]+)(\\))/i";
        }

        // Callback for regexp replacement
        $callback = function ($matches) use ($code) {
            // Decode HTML attribute
            $URL = Convert::xml2raw($matches[2]);
            $rewritten = $code($URL);
            return $matches[1] . Convert::raw2xml($rewritten) . $matches[3];
        };

        // Execute each expression
        foreach ($regExps as $regExp) {
            $content = preg_replace_callback($regExp, $callback, $content);
        }

        return $content;
    }

    /**
     * Will try to include a GET parameter for an existing URL, preserving existing parameters and
     * fragments. If no URL is given, falls back to $_SERVER['REQUEST_URI']. Uses parse_url() to
     * dissect the URL, and http_build_query() to reconstruct it with the additional parameter.
     * Converts any '&' (ampersand) URL parameter separators to the more XHTML compliant '&amp;'.
     *
     * CAUTION: If the URL is determined to be relative, it is prepended with Director::absoluteBaseURL().
     * This method will always return an absolute URL because Director::makeRelative() can lead to
     * inconsistent results.
     *
     * @param string $varname
     * @param string $varvalue
     * @param string|null $currentURL Relative or absolute URL, or HTTPRequest to get url from
     * @param string $separator Separator for http_build_query().
     * @return string
     */
    public static function setGetVar($varname, $varvalue, $currentURL = null, $separator = '&')
    {
        if (!isset($currentURL)) {
            $request = Controller::curr()->getRequest();
            $currentURL = $request->getURL(true);
        }
        $uri = $currentURL;

        $isRelative = false;
        // We need absolute URLs for parse_url()
        if (Director::is_relative_url($uri)) {
            $uri = Director::absoluteBaseURL() . $uri;
            $isRelative = true;
        }

        // try to parse uri
        $parts = parse_url($uri);
        if (!$parts) {
            throw new InvalidArgumentException("Can't parse URL: " . $uri);
        }

        // Parse params and add new variable
        $params = array();
        if (isset($parts['query'])) {
            parse_str($parts['query'], $params);
        }
        $params[$varname] = $varvalue;

        // Generate URI segments and formatting
        $scheme = (isset($parts['scheme'])) ? $parts['scheme'] : 'http';
        $user = (isset($parts['user']) && $parts['user'] != '')  ? $parts['user'] : '';

        if ($user != '') {
            // format in either user:pass@host.com or user@host.com
            $user .= (isset($parts['pass']) && $parts['pass'] != '') ? ':' . $parts['pass'] . '@' : '@';
        }

        $host = (isset($parts['host'])) ? $parts['host'] : '';
        $port = (isset($parts['port']) && $parts['port'] != '') ? ':' . $parts['port'] : '';
        $path = (isset($parts['path']) && $parts['path'] != '') ? $parts['path'] : '';

        // handle URL params which are existing / new
        $params = ($params) ?  '?' . http_build_query($params, null, $separator) : '';

        // keep fragments (anchors) intact.
        $fragment = (isset($parts['fragment']) && $parts['fragment'] != '') ?  '#' . $parts['fragment'] : '';

        // Recompile URI segments
        $newUri =  $scheme . '://' . $user . $host . $port . $path . $params . $fragment;

        if ($isRelative) {
            return Director::makeRelative($newUri);
        }

        return $newUri;
    }

    /**
     * @param string $varname
     * @param string $varvalue
     * @param null|string $currentURL
     *
     * @return string
     */
    public static function RAW_setGetVar($varname, $varvalue, $currentURL = null)
    {
        $url = self::setGetVar($varname, $varvalue, $currentURL);
        return Convert::xml2raw($url);
    }

    /**
     * Search for all tags with a specific attribute, then return the value of that attribute in a
     * flat array.
     *
     * @param string $content
     * @param array $attributes An array of tags to attributes, for example "[a] => 'href', [div] => 'id'"
     *
     * @return array
     */
    public static function findByTagAndAttribute($content, $attributes)
    {
        $regexes = array();

        foreach ($attributes as $tag => $attribute) {
            $regexes[] = "/<{$tag} [^>]*$attribute *= *([\"'])(.*?)\\1[^>]*>/i";
            $regexes[] = "/<{$tag} [^>]*$attribute *= *([^ \"'>]+)/i";
        }

        $result = array();

        if ($regexes) {
            foreach ($regexes as $regex) {
                if (preg_match_all($regex, $content, $matches)) {
                    $result = array_merge_recursive($result, (isset($matches[2]) ? $matches[2] : $matches[1]));
                }
            }
        }

        return count($result) ? $result : null;
    }

    /**
     * @param string $content
     *
     * @return array
     */
    public static function getLinksIn($content)
    {
        return self::findByTagAndAttribute($content, array("a" => "href"));
    }

    /**
     * @param string $content
     *
     * @return array
     */
    public static function getImagesIn($content)
    {
        return self::findByTagAndAttribute($content, array("img" => "src"));
    }

    /**
     * Get the MIME type based on a file's extension. If the finfo class exists in PHP, and the file
     * exists relative to the project root, then use that extension, otherwise fallback to a list of
     * commonly known MIME types.
     *
     * @param string $filename
     *
     * @return string
     */
    public static function get_mime_type($filename)
    {
        // If the finfo module is compiled into PHP, use it.
        $path = BASE_PATH . DIRECTORY_SEPARATOR . $filename;
        if (class_exists('finfo') && file_exists($path)) {
            $finfo = new finfo(FILEINFO_MIME_TYPE);
            return $finfo->file($path);
        }

        // Fallback to use the list from the HTTP.yml configuration and rely on the file extension
        // to get the file mime-type
        $ext = strtolower(File::get_file_extension($filename));
        // Get the mime-types
        $mimeTypes = HTTP::config()->uninherited('MimeTypes');

        // The mime type doesn't exist
        if (!isset($mimeTypes[$ext])) {
            return 'application/unknown';
        }

        return $mimeTypes[$ext];
    }

    /**
     * Set the maximum age of this page in web caches, in seconds.
     *
     * @param int $age
     */
    public static function set_cache_age($age)
    {
        self::$cache_age = $age;
    }

    /**
     * @param string $dateString
     */
    public static function register_modification_date($dateString)
    {
        $timestamp = strtotime($dateString);
        if ($timestamp > self::$modification_date) {
            self::$modification_date = $timestamp;
        }
    }

    /**
     * @param int $timestamp
     */
    public static function register_modification_timestamp($timestamp)
    {
        if ($timestamp > self::$modification_date) {
            self::$modification_date = $timestamp;
        }
    }

    /**
     * @param string $etag
     */
    public static function register_etag($etag)
    {
        if (0 !== strpos($etag, '"')) {
            $etag = sprintf('"%s"', $etag);
        }
        self::$etag = $etag;
    }

    /**
     * Add the appropriate caching headers to the response, including If-Modified-Since / 304 handling.
     * Note that setting HTTP::$cache_age will overrule any cache headers set by PHP's
     * session_cache_limiter functionality. It is your responsibility to ensure only cacheable data
     * is in fact cached, and HTTP::$cache_age isn't set when the HTTP body contains session-specific
     * content.
     *
     * Omitting the $body argument or passing a string is deprecated; in these cases, the headers are
     * output directly.
     *
     * @param HTTPResponse $body
     */
    public static function add_cache_headers($body = null)
    {
        $cacheAge = self::$cache_age;

        // Validate argument
        if ($body && !($body instanceof HTTPResponse)) {
            user_error("HTTP::add_cache_headers() must be passed an HTTPResponse object", E_USER_WARNING);
            $body = null;
        }

        // Development sites have frequently changing templates; this can get stuffed up by the code
        // below.
        if (Director::isDev()) {
            $cacheAge = 0;
        }

        // The headers have been sent and we don't have an HTTPResponse object to attach things to; no point in
        // us trying.
        if (headers_sent() && !$body) {
            return;
        }

        // Populate $responseHeaders with all the headers that we want to build
        $responseHeaders = array();

        $cacheControlHeaders = HTTP::config()->uninherited('cache_control');


        // currently using a config setting to cancel this, seems to be so that the CMS caches ajax requests
        if (function_exists('apache_request_headers') && static::config()->uninherited('cache_ajax_requests')) {
            $requestHeaders = array_change_key_case(apache_request_headers(), CASE_LOWER);
            if (isset($requestHeaders['x-requested-with'])
                && $requestHeaders['x-requested-with']=='XMLHttpRequest'
            ) {
                $cacheAge = 0;
            }
        }

        if ($cacheAge > 0) {
            $cacheControlHeaders['max-age'] = self::$cache_age;

            // Set empty pragma to avoid PHP's session_cache_limiter adding conflicting caching information,
            // defaulting to "nocache" on most PHP configurations (see http://php.net/session_cache_limiter).
            // Since it's a deprecated HTTP 1.0 option, all modern HTTP clients and proxies should
            // prefer the caching information indicated through the "Cache-Control" header.
            $responseHeaders["Pragma"] = "";

            // To do: User-Agent should only be added in situations where you *are* actually
            // varying according to user-agent.
            $vary = HTTP::config()->uninherited('vary');
            if ($vary && strlen($vary)) {
                $responseHeaders['Vary'] = $vary;
            }
        } else {
            $contentDisposition = null;
            if ($body) {
                // Grab header for checking. Unfortunately HTTPRequest uses a mistyped variant.
                $contentDisposition = $body->getHeader('Content-disposition');
                if (!$contentDisposition) {
                    $contentDisposition = $body->getHeader('Content-Disposition');
                }
            }

            if ($body &&
                Director::is_https() &&
                isset($_SERVER['HTTP_USER_AGENT']) &&
                strstr($_SERVER['HTTP_USER_AGENT'], 'MSIE')==true &&
                strstr($contentDisposition, 'attachment;')==true
            ) {
                // IE6-IE8 have problems saving files when https and no-cache are used
                // (http://support.microsoft.com/kb/323308)
                // Note: this is also fixable by ticking "Do not save encrypted pages to disk" in advanced options.
                $cacheControlHeaders['max-age'] = 3;

                // Set empty pragma to avoid PHP's session_cache_limiter adding conflicting caching information,
                // defaulting to "nocache" on most PHP configurations (see http://php.net/session_cache_limiter).
                // Since it's a deprecated HTTP 1.0 option, all modern HTTP clients and proxies should
                // prefer the caching information indicated through the "Cache-Control" header.
                $responseHeaders["Pragma"] = "";
            } else {
                $cacheControlHeaders['no-cache'] = "true";
                $cacheControlHeaders['no-store'] = "true";
            }
        }

        foreach ($cacheControlHeaders as $header => $value) {
            if (is_null($value)) {
                unset($cacheControlHeaders[$header]);
            } elseif ((is_bool($value) && $value) || $value === "true") {
                $cacheControlHeaders[$header] = $header;
            } else {
                $cacheControlHeaders[$header] = $header . "=" . $value;
            }
        }

        $responseHeaders['Cache-Control'] = implode(', ', $cacheControlHeaders);
        unset($cacheControlHeaders, $header, $value);

        if (self::$modification_date && $cacheAge > 0) {
            $responseHeaders["Last-Modified"] = self::gmt_date(self::$modification_date);

            // Chrome ignores Varies when redirecting back (http://code.google.com/p/chromium/issues/detail?id=79758)
            // which means that if you log out, you get redirected back to a page which Chrome then checks against
            // last-modified (which passes, getting a 304)
            // when it shouldn't be trying to use that page at all because it's the "logged in" version.
            // By also using and etag that includes both the modification date and all the varies
            // values which we also check against we can catch this and not return a 304
            $etagParts = array(self::$modification_date, serialize($_COOKIE));
            $etagParts[] = Director::is_https() ? 'https' : 'http';
            if (isset($_SERVER['HTTP_USER_AGENT'])) {
                $etagParts[] = $_SERVER['HTTP_USER_AGENT'];
            }
            if (isset($_SERVER['HTTP_ACCEPT'])) {
                $etagParts[] = $_SERVER['HTTP_ACCEPT'];
            }

            $etag = sha1(implode(':', $etagParts));
            $responseHeaders["ETag"] = $etag;

            // 304 response detection
            if (isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])) {
                $ifModifiedSince = strtotime(stripslashes($_SERVER['HTTP_IF_MODIFIED_SINCE']));

                // As above, only 304 if the last request had all the same varies values
                // (or the etag isn't passed as part of the request - but with chrome it always is)
                $matchesEtag = !isset($_SERVER['HTTP_IF_NONE_MATCH']) || $_SERVER['HTTP_IF_NONE_MATCH'] == $etag;

                if ($ifModifiedSince >= self::$modification_date && $matchesEtag) {
                    if ($body) {
                        $body->setStatusCode(304);
                        $body->setBody('');
                    } else {
                        header('HTTP/1.0 304 Not Modified');
                        die();
                    }
                }
            }

            $expires = time() + $cacheAge;
            $responseHeaders["Expires"] = self::gmt_date($expires);
        }

        if (self::$etag) {
            $responseHeaders['ETag'] = self::$etag;
        }

        // etag needs to be a quoted string according to HTTP spec
        if (!empty($responseHeaders['ETag']) && 0 !== strpos($responseHeaders['ETag'], '"')) {
            $responseHeaders['ETag'] = sprintf('"%s"', $responseHeaders['ETag']);
        }

        // Now that we've generated them, either output them or attach them to the HTTPResponse as appropriate
        foreach ($responseHeaders as $k => $v) {
            if ($body) {
                // Set the header now if it's not already set.
                if ($body->getHeader($k) === null) {
                    $body->addHeader($k, $v);
                }
            } elseif (!headers_sent()) {
                header("$k: $v");
            }
        }
    }


    /**
     * Return an {@link http://www.faqs.org/rfcs/rfc2822 RFC 2822} date in the GMT timezone (a timestamp
     * is always in GMT: the number of seconds since January 1 1970 00:00:00 GMT)
     *
     * @param int $timestamp
     *
     * @return string
     */
    public static function gmt_date($timestamp)
    {
        return gmdate('D, d M Y H:i:s', $timestamp) . ' GMT';
    }

    /**
     * Return static variable cache_age in second
     *
     * @return int
     */
    public static function get_cache_age()
    {
        return self::$cache_age;
    }
}
