<?php

/**
 * @package Dotclear
 *
 * @copyright Olivier Meunier & Association Dotclear
 * @copyright AGPL-3.0
 */
declare(strict_types=1);

namespace Dotclear\Helper\Network;

use Dotclear\Helper\Crypt;
use Exception;

/**
 * @class Http
 *
 * HTTP utilities
 */
class Http
{
    /**
     * Force HTTPS scheme on server port 443 in {@link getHost()}
     *
     * @var        bool     $https_scheme_on_443
     */
    public static $https_scheme_on_443 = false;

    /**
     * Cache max age for {@link cache()}
     *
     * @var        int      $cache_max_age
     */
    public static $cache_max_age = 0;

    /**
     * use X-FORWARD headers on getHost()
     *
     * @var        bool     $reverse_proxy
     */
    public static $reverse_proxy = false;

    /**
     * Self root URI, returns current scheme, host and port.
     */
    public static function getHost(): string
    {
        if (self::$reverse_proxy && isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
            //admin have choose to allow a reverse proxy,
            //and HTTP_X_FORWARDED_FOR header means it's beeing using

            if (!isset($_SERVER['HTTP_X_FORWARDED_PROTO'])) {
                throw new Exception('Reverse proxy parametter is setted, header HTTP_X_FORWARDED_FOR is found but not the X-Forwarded-Proto. Please check your reverse proxy server settings');
            }

            $scheme = is_string($scheme = $_SERVER['HTTP_X_FORWARDED_PROTO']) ? $scheme : 'http';

            if (isset($_SERVER['HTTP_HOST']) && is_string($_SERVER['HTTP_HOST'])) {
                $name_port_array = explode(':', $_SERVER['HTTP_HOST']);
            } else {
                // Fallback to server name and port
                $name_port_array = [
                    $_SERVER['SERVER_NAME'],
                    $_SERVER['SERVER_PORT'],
                ];
            }

            $server_name = is_string($server_name = $name_port_array[0]) ? $server_name : '';

            $port = isset($name_port_array[1]) && is_string($name_port_array[1]) ? ':' . $name_port_array[1] : '';
            if (($port === ':80' && $scheme === 'http')
                || ($port === ':443' && $scheme === 'https')
                || ($port === ':0')
            ) {
                $port = '';
            }

            return $scheme . '://' . $server_name . $port;
        }

        if (isset($_SERVER['HTTP_HOST']) && is_string($_SERVER['HTTP_HOST'])) {
            $server_name = explode(':', $_SERVER['HTTP_HOST']);
            $server_name = $server_name[0];
        } else {
            // Fallback to server name
            $server_name = isset($_SERVER['SERVER_NAME']) && is_string($server_name = $_SERVER['SERVER_NAME']) ? $server_name : '';
        }

        $port_number = isset($_SERVER['SERVER_PORT']) && is_numeric($port_number = $_SERVER['SERVER_PORT']) ? abs((int) $port_number) : 0;
        if (self::$https_scheme_on_443 && ($port_number === 443 || $port_number === 0)) {
            $scheme = 'https';
            // No need to keep port number if HTTPS scheme is forced on port 443
            $port = '';
        } elseif (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') {
            $scheme = 'https';
            // No need to keep port number if port is 80, 443 or 0
            $port = in_array($port_number, [80, 443, 0], true) ? '' : ':' . $port_number;
        } else {
            $scheme = 'http';
            // No need to keep port number if port is 80 or 0
            $port = in_array($port_number, [80, 0], true) ? '' : ':' . $port_number;
        }

        return $scheme . '://' . $server_name . $port;
    }

    /**
     * Self root URI
     *
     * Returns current scheme and host from a static URL.
     *
     * @param string    $url URL to retrieve the host from.
     */
    public static function getHostFromURL(string $url): string
    {
        preg_match('~^(?:((?:[a-z]+:)?//)|:(//))?(?:([^:\r\n]*?)/[^:\r\n]*|([^:\r\n]*))$~', $url, $matches);
        array_shift($matches);

        return implode('', $matches);
    }

    /**
     * Self URI
     *
     * Returns current URI with full hostname.
     */
    public static function getSelfURI(): string
    {
        $request_uri = isset($_SERVER['REQUEST_URI']) && is_string($request_uri = $_SERVER['REQUEST_URI']) ? $request_uri : '';
        if (!str_starts_with($request_uri, '/')) {
            return self::getHost() . '/' . $request_uri;
        }

        return self::getHost() . $request_uri;
    }

    /**
     * Prepare a full redirect URI from a relative or absolute URL
     *
     * @param      string $relative_url Relative URL
     */
    protected static function prepareRedirect(string $relative_url): string
    {
        if (preg_match('%^http[s]?://%', $relative_url)) {
            $full_url = $relative_url;
        } else {
            $host = self::getHost();

            if (str_starts_with($relative_url, '/')) {
                $full_url = $host . $relative_url;
            } else {
                $php_self = isset($_SERVER['PHP_SELF']) && is_string($php_self = $_SERVER['PHP_SELF']) ? $php_self : '';
                $path     = str_replace(DIRECTORY_SEPARATOR, '/', dirname($php_self));
                if (str_ends_with($path, '/')) {
                    $path = substr($path, 0, -1);
                }

                if ($path === '.') {
                    $path = '';
                }

                $full_url = $host . $path . '/' . $relative_url;
            }
        }

        return $full_url;
    }

    /**
     * Redirect
     *
     * Performs a conforming HTTP redirect for a relative URL.
     *
     * @param string    $relative_url        Relative URL
     */
    public static function redirect(string $relative_url): string
    {
        # Close session if exists
        if (session_id()) {
            session_write_close();
        }

        header('Location: ' . self::prepareRedirect($relative_url));

        dotclear_exit();
    }

    /**
     * Concat URL and path
     *
     * Appends a path to a given URL. If path begins with "/" it will replace the original URL path.
     *
     * @param string    $url        URL
     * @param string    $path       Path to append
     */
    public static function concatURL(string $url, string $path): string
    {
        // Ensure there is a trailing slash
        if (!str_ends_with($url, '/')) {
            $url .= '/';
        }

        if (!str_starts_with($path, '/')) {
            return $url . $path;
        }

        return (string) preg_replace('#^(.+?//.+?)/(.*)$#', '$1' . $path, $url);
    }

    /**
     * Real IP
     *
     * Returns the real client IP (or tries to do its best).
     */
    public static function realIP(): ?string
    {
        return isset($_SERVER['REMOTE_ADDR']) && is_string($ip = $_SERVER['REMOTE_ADDR']) ? $ip : null;
    }

    /**
     * Client unique ID
     *
     * Returns a "almost" safe client unique ID.
     *
     * @param string    $key        HMAC key
     */
    public static function browserUID(string $key): string
    {
        $user_agent     = isset($_SERVER['HTTP_USER_AGENT'])     && is_string($user_agent = $_SERVER['HTTP_USER_AGENT']) ? $user_agent : '';
        $accept_charset = isset($_SERVER['HTTP_ACCEPT_CHARSET']) && is_string($accept_charset = $_SERVER['HTTP_ACCEPT_CHARSET']) ? $accept_charset : '';

        return Crypt::hmac($key, $user_agent . $accept_charset);
    }

    /**
     * Client language
     *
     * Returns a two letters language code take from HTTP_ACCEPT_LANGUAGE.
     */
    public static function getAcceptLanguage(): string
    {
        $client_language_code = '';
        $accept_language      = isset($_SERVER['HTTP_ACCEPT_LANGUAGE']) && is_string($accept_language = $_SERVER['HTTP_ACCEPT_LANGUAGE']) ? $accept_language : '';

        if ($accept_language !== '') {
            $accepted_languages       = explode(',', $accept_language);
            $first_acccepted_language = explode(';', $accepted_languages[0]);
            $client_language_code     = substr(trim($first_acccepted_language[0]), 0, 2);
        }

        return $client_language_code;
    }

    /**
     * Client languages
     *
     * Returns an array of accepted langages ordered by priority.
     * can be a two letters language code or a xx-xx variant.
     *
     * @return array<string>
     */
    public static function getAcceptLanguages(): array
    {
        $accepted_languages = [];
        $accept_language    = isset($_SERVER['HTTP_ACCEPT_LANGUAGE']) && is_string($accept_language = $_SERVER['HTTP_ACCEPT_LANGUAGE']) ? $accept_language : '';

        if ($accept_language !== '') {
            // break up string into pieces (languages and q factors)
            preg_match_all(
                '/([a-z]{1,8}(-[a-z]{1,8})?)\s*(;\s*q\s*=\s*(1|0\.[0-9]+))?/i',
                $accept_language,
                $matches
            );

            // create a list like "en" => 0.8
            $accepted_languages = array_combine($matches[1], $matches[4]);

            // set default to 1 for any without q factor
            foreach ($accepted_languages as $language => $q_factor) {
                if ($q_factor === '') {
                    $accepted_languages[$language] = 1;
                }
            }

            // sort list based on value
            arsort($accepted_languages, SORT_NUMERIC);
            $accepted_languages = array_map(strtolower(...), array_keys($accepted_languages));
        }

        return $accepted_languages;
    }

    /**
     * HTTP Cache
     *
     * Sends HTTP cache headers (304) according to a list of files and an optionnal.
     * list of timestamps.
     *
     * @param array<string>        $mod_files           Files on which check mtime
     * @param array<int>           $mod_timestamps      List of timestamps
     */
    public static function cache(array $mod_files, array $mod_timestamps = []): void
    {
        $mod_files = array_filter($mod_files);
        if ($mod_files === []) {
            return;
        }

        // Replace each files in array by its last modification timestamp
        array_walk($mod_files, function (string &$mod_timestamp): void {
            $mod_timestamp = filemtime($mod_timestamp);
        });

        // Merge both array of timestamps
        $timestamps = array_merge($mod_timestamps, $mod_files);

        // Sort (reverse) the resulting timestamps: most recent first [0]
        rsort($timestamps);

        $now       = time();
        $timestamp = min($timestamps[0], $now);

        $since             = null;
        $if_modified_since = isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) && is_string($if_modified_since = $_SERVER['HTTP_IF_MODIFIED_SINCE']) ? $if_modified_since : '';
        if ($if_modified_since !== '') {
            $since = (string) preg_replace('/^(.*)(Mon|Tue|Wed|Thu|Fri|Sat|Sun)(.*)(GMT)(.*)/', '$2$3 GMT', $if_modified_since);
            $since = strtotime($since);
            $since = ($since <= $now) ? $since : null;
        }

        # Common headers list
        $headers[] = 'Last-Modified: ' . gmdate('D, d M Y H:i:s', $timestamp ? (int) $timestamp : null) . ' GMT';
        $headers[] = 'Cache-Control: must-revalidate, max-age=' . abs((int) self::$cache_max_age);

        if ($since >= $timestamp) {
            self::head(304, 'Not Modified');
            foreach ($headers as $header) {
                header($header);
            }

            dotclear_exit();
        }

        header('Date: ' . gmdate('D, d M Y H:i:s', $now) . ' GMT');
        foreach ($headers as $header) {
            header($header);
        }
    }

    /**
     * HTTP Etag
     *
     * Sends HTTP cache headers (304) according to a list of etags in client request.
     */
    public static function etag(string ...$args): void
    {
        if ($args === []) {
            return;
        }

        $etag = '"' . md5(implode('', $args)) . '"';
        header('ETag: ' . $etag);

        // Do we have a previously sent content?
        $if_none_match = isset($_SERVER['HTTP_IF_NONE_MATCH']) && is_string($if_none_match = $_SERVER['HTTP_IF_NONE_MATCH']) ? $if_none_match : '';
        if ($if_none_match !== '') {
            foreach (explode(',', $if_none_match) as $i) {
                if (stripslashes(trim($i)) === $etag) {
                    self::head(304, 'Not Modified');

                    dotclear_exit();
                }
            }
        }
    }

    /**
     * HTTP Header
     *
     * Sends an HTTP code and message to client.
     *
     * @param int       $code        HTTP code
     * @param string    $msg         Message
     */
    public static function head(int $code, $msg = null): void
    {
        $status_mode = preg_match('/cgi/', PHP_SAPI);

        if (!$msg) {
            $msg_codes = [
                100 => 'Continue',
                101 => 'Switching Protocols',
                200 => 'OK',
                201 => 'Created',
                202 => 'Accepted',
                203 => 'Non-Authoritative Information',
                204 => 'No Content',
                205 => 'Reset Content',
                206 => 'Partial Content',
                300 => 'Multiple Choices',
                301 => 'Moved Permanently',
                302 => 'Found',
                303 => 'See Other',
                304 => 'Not Modified',
                305 => 'Use Proxy',
                307 => 'Temporary 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 Timeout',
                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 Not Satisfiable',
                417 => 'Expectation Failed',
                500 => 'Internal Server Error',
                501 => 'Not Implemented',
                502 => 'Bad Gateway',
                503 => 'Service Unavailable',
                504 => 'Gateway Timeout',
                505 => 'HTTP Version Not Supported',
            ];

            $msg = $msg_codes[$code] ?? '-';
        }

        if ($status_mode) {
            header('Status: ' . $code . ' ' . $msg);
        } else {
            header($msg, true, $code);
        }
    }

    /**
     * Trim request
     *
     * Trims every value in GET, POST, REQUEST and COOKIE vars.
     */
    public static function trimRequest(): void
    {
        $cleanup = function (&$value): void {
            $value = is_string($value) ? trim($value) : $value;
        };

        if ($_GET !== []) {
            array_walk_recursive($_GET, $cleanup);
        }

        if ($_POST !== []) {
            array_walk_recursive($_POST, $cleanup);
        }

        if ($_REQUEST !== []) {
            array_walk_recursive($_REQUEST, $cleanup);
        }

        if ($_COOKIE !== []) {
            array_walk_recursive($_COOKIE, $cleanup);
        }
    }
}
