<?php
# Lifter007: TODO
/*
 * Request.php - class representing a HTTP request in Stud.IP
 *
 * Copyright (c) 2009  Elmar Ludwig
 *
 * This program is free software; you can redistribute it and/or
 * modify it under the terms of the GNU General Public License as
 * published by the Free Software Foundation; either version 2 of
 * the License, or (at your option) any later version.
 */

/**
 * Singleton class representing a HTTP request in Stud.IP.
 */
class Request implements ArrayAccess, IteratorAggregate
{
    /**
     * cached request parameter array
     */
    private $params;

    /**
     * Initialize a new Request instance.
     */
    private function __construct()
    {
        $this->params = array_merge($_GET, $_POST);
    }

    /**
     * Return the Request singleton instance.
     */
    public static function getInstance()
    {
        static $instance;

        if (isset($instance)) {
            return $instance;
        }

        return $instance = new Request();
    }

    /**
     * ArrayAccess: Check whether the given offset exists.
     */
    public function offsetExists($offset): bool
    {
        return isset($this->params[$offset]);
    }

    /**
     * ArrayAccess: Get the value at the given offset.
     */
    public function offsetGet($offset): mixed
    {
        return $this->params[$offset] ?? null;
    }

    /**
     * ArrayAccess: Set the value at the given offset.
     */
    public function offsetSet($offset, $value): void
    {
        $this->params[$offset] = $value;
    }

    /**
     * ArrayAccess: Delete the value at the given offset.
     */
    public function offsetUnset($offset): void
    {
        unset($this->params[$offset]);
    }

    /**
     * IteratorAggregate: Create iterator for request parameters.
     *
     * @return ArrayIterator
     */
    public function getIterator(): Traversable
    {
        return new ArrayIterator((array)$this->params);
    }

    /**
     * Return the current URL, including query parameters.
     */
    public static function url()
    {
        return self::protocol() . '://' . self::server() . self::path();
    }

    /**
     * Return the current protocol ('http' or 'https').
     */
    public static function protocol()
    {

        // If a reverse proxy tells us the required protocol we should respect that
        if (isset($_SERVER['HTTP_X_FORWARDED_PROTO'])) {
            return $_SERVER['HTTP_X_FORWARDED_PROTO'];
        }

        return (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on')
            ? 'https'
            : 'http';
    }

    /**
     * Return the current server name and port (host:port).
     */
    public static function server()
    {
        $host = $_SERVER['SERVER_NAME'];
        $port = $_SERVER['SERVER_PORT'];
        $ssl  = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on';

        if ($ssl && $port != 443 || !$ssl && $port != 80) {
            $host .= ':' . $port;
        }

        return $host;
    }

    /**
     * Return the current request path, relative to the server root.
     */
    public static function path()
    {
        return $_SERVER['REQUEST_URI'];
    }

    /**
     * Return the filename of the currently executing script.
     * @return string
     */
    public static function scriptName(): string
    {
        return $_SERVER['SCRIPT_NAME'];
    }

    /**
     * Returns the complete path info including duplicated slashes.
     * $_SERVER['PATH_INFO'] will remove them.
     */
    public static function pathInfo(): string
    {
        $script_name = self::scriptName();
        $script_name = preg_quote($script_name, '#');
        $script_name = str_replace('/', '/+', $script_name);

        $path_info = preg_replace(
            "#^{$script_name}#",
            '',
            urldecode(self::path())
        );
        return parse_url($path_info, PHP_URL_PATH) ?? '';
    }

    /**
     * Set the selected query parameter to a specific value.
     *
     * @param string $param    parameter name
     * @param mixed  $value    parameter value (string or array)
     */
    public static function set($param, $value)
    {
        $request = self::getInstance();

        $request->params[$param] = $value;
    }

    /**
     * Return the value of the selected query parameter as a string.
     *
     * @param string $param    parameter name
     * @param string $default  default value if parameter is not set
     *
     * @return string  parameter value as string (if set), else NULL
     */
    public static function get($param, $default = NULL)
    {
        $request = self::getInstance();

        return (isset($request[$param]) && is_string($request[$param]))
             ? $request[$param]
             : $default;
    }

    /**
     * Return the value of the selected query parameter as an I18NString.
     *
     * @param string                 $param   parameter name
     * @param I18NString|string|null $default default value if parameter is not set
     * @param callable|null          $op      Operation to perform on each text string
     *
     * @return I18NString  parameter value as string (if set), else NULL
     */
    public static function i18n(string $param, $default = null, Callable $op = null)
    {
        $value = self::get($param, $default instanceof I18NString ? $default->original() : $default);

        if (isset($value)) {
            $lang = self::getArray($param . '_i18n') ?: ($default instanceof I18NString ? $default->toArray() : []);

            if ($op) {
                $value = $op($value);
                $lang  = array_map($op, $lang);
            }

            $value = new I18NString($value, $lang);
        }

        return $value;
    }

    /**
     * Return the value of the selected query parameter as an alphanumeric
     * string (consisting of only digits, letters and underscores).
     *
     * @param string $param    parameter name
     * @param string $default  default value if parameter is not set
     *
     * @return string  parameter value as string (if set), else NULL
     */
    public static function option($param, $default = NULL)
    {
        $value = self::get($param, $default);

        if (!isset($value) || preg_match('/\\W/', $value)) {
            $value = $default;
        }

        return $value;
    }

    /**
     * Return the value of the selected query parameter as an integer.
     *
     * @param string $param    parameter name
     * @param int    $default  default value if parameter is not set
     *
     * @return int     parameter value as integer (if set), else NULL
     */
    public static function int($param, $default = NULL)
    {
        $value = self::get($param, $default);

        if (isset($value)) {
            $value = (int) $value;
        }

        return $value;
    }

    /**
     * Return the value of the selected query parameter as a float.
     *
     * @param string $param    parameter name
     * @param float  $default  default value if parameter is not set
     *
     * @return float   parameter value as float (if set), else NULL
     */
    public static function float($param, $default = NULL)
    {
        $value = self::get($param, $default);

        if (isset($value)) {
            $value = (float) strtr($value, ',', '.');
        }

        return $value;
    }


    /**
     * Returns the date and time values from one or two fields
     * as a DateTime object. The $second_param and $second_format
     * parameters are handy in case the date and time values
     * come from different fields.
     *
     * @param string $param The name of the date/time field.
     * @param string $format The date format of the date/time field.
     * @param string $second_param The name of the second field, if used.
     *     This parameter is optional.
     * @param string $second_format The time format of the second field, if used.
     *     This parameter is optional.
     * @param DateTime|null $default Either a default DateTime object
     *     or null if no default shall be set. In the latter case a DateTime
     *     object representing the unix timestamp 0 is returned.
     *
     * @returns DateTime|bool A DateTime object containing the
     *     date and time values of the specified date and time field.
     *     In case something went wrong the boolean value false is returned.
     *
     * @see the following PHP documentation page for a list of
     *     accepted date and time formats:
     *     https://secure.php.net/manual/en/datetime.createfromformat.php
     */
    public static function getDateTime(
        $param = 'date',
        $format = 'Y-m-d',
        $second_param = null,
        $second_format = null,
        $default = null
    )
    {
        $value = self::get($param);

        if ($value) {
            // Combine the format specifications and the
            // values into one string each.
            $combined_format = $format;
            $combined_value = $value;

            // The second format and value is only added
            // when $second_param and $second_format are set
            // and a second value could be retrieved.
            if ($second_param && $second_format) {
                $second_value = Request::get($second_param);
                if ($second_value) {
                    $combined_format .= ' ' . $second_format;
                    $combined_value .= ' ' . $second_value;
                }
            }

            // The time zone may not be set in the fields
            // so we use the default timezone from a new
            // DateTime object:
            $value = new DateTime();
            $time_zone = $value->getTimezone();

            // Now we return a DateTime object created from the
            // specified date value(s):
            $result = DateTime::createFromFormat(
                $combined_format,
                $combined_value,
                $time_zone
            );

            if ($result !== false) {
                if (
                    !$second_param
                    && !$second_format
                    && !preg_match('/[aAghGHisvu]/', $format) // Ensure no time in format
                ) {
                    $result->setTime(0, 0);
                }

                return $result;
            }
        }


        // In case the first field is not set
        // use the default value, if any:
        if ($default instanceof DateTime) {
            return $default;
        }

        return false;
    }


    /**
     * Retrieves a parameter that stores time data and converts the time data
     * to a DateTime object. If a date is specified using an existing DateTime
     * object, the time will be set on the existing DateTime object and the
     * modified object is returned.
     *
     * @param string $param The name of the time field.
     *
     * @param string $format The time format of the time field.
     *
     * @param DateTime|null $date An optional DateTime object whose time
     *     shall be set from the specified parameter.
     *
     * @returns DateTime|bool A DateTime object containing the
     *     time value of the specified date and time field.
     *     In case something went wrong the boolean value false is returned.
     *
     */
    public static function getTime(
        $param = 'time',
        $format = 'H:i',
        $date = null
    )
    {
        $value = Request::get($param);

        //Get the timezone before parsing the time
        //so that the resulting DateTime object
        //will have the current timezone set.
        $tz_get = new DateTime();
        $time_zone = $tz_get->getTimezone();
        $converted_value = DateTime::createFromFormat(
            $format,
            $value,
            $time_zone
        );

        if ($date instanceof DateTime) {
            //Modify the time information of the specified
            //DateTime object and return the modified object.
            $date->setTime(
                intval($converted_value->format('H')),
                intval($converted_value->format('i')),
                intval($converted_value->format('s'))
            );

            return $date;
        }

        //Return the time value.
        return $converted_value;
    }


    /**
     * Return the value of the selected query parameter as a string
     * consisting only of allowed characters for usernames.
     *
     * @param string $param    parameter name
     * @param string  $default  default value if parameter is not set
     *
     * @return string   parameter value (if set), else NULL
     */
    public static function username($param, $default = NULL)
    {
        $value = self::get($param, $default);

        if (!isset($value) || !preg_match(Config::get()->USERNAME_REGULAR_EXPRESSION, $value)) {
            $value = $default;
        }

        return $value;
    }

    /**
     * Return the value of the selected query parameter as an array.
     *
     * @param string $param    parameter name
     *
     * @return array  parameter value as array (if set), else an empty array
     */
    public static function getArray($param)
    {
        $request = self::getInstance();

        return (isset($request[$param]) && is_array($request[$param]))
            ? $request[$param]
            : [];
    }

    /**
     * Return the value of the selected query parameter as an array of
     * alphanumeric strings (consisting of only digits, letters and
     * underscores).
     *
     * @param string $param    parameter name
     *
     * @return array  parameter value as array (if set), else an empty array
     */
    public static function optionArray($param)
    {
        $array = self::getArray($param);

        foreach ($array as $key => $value) {
            if (preg_match('/\\W/', $value)) {
                unset($array[$key]);
            }
        }

        return $array;
    }

    /**
     * Return the value of the selected query parameter as an integer array.
     *
     * @param string $param    parameter name
     *
     * @return array  parameter value as array (if set), else an empty array
     */
    public static function intArray($param)
    {
        $array = self::getArray($param);

        foreach ($array as $key => $value) {
            $array[$key] = (int) $value;
        }

        return $array;
    }

    /**
     * Return the value of the selected query parameter as a float array.
     *
     * @param string $param    parameter name
     *
     * @return array  parameter value as array (if set), else an empty array
     */
    public static function floatArray($param)
    {
        $array = self::getArray($param);

        foreach ($array as $key => $value) {
            $array[$key] = (float) strtr($value, ',', '.');
        }

        return $array;
    }

    /**
     * Return the value of the selected query parameter as a boolean.
     *
     * @param string $param   parameter name
     * @param bool   $default default value if parameter is not set
     *
     * @return bool parameter value as bool (if set), else NULL
     *
     * @since Stud.IP 4.4
     */
    public static function bool($param, $default = null)
    {
        $value = self::get($param, $default);

        if (isset($value)) {
            $value = (bool) $value;
        }

        return $value;
    }

    /**
     * Return the value of the selected query parameter as a boolean array.
     *
     * @param string $param    parameter name
     *
     * @return array  parameter value as array (if set), else an empty array
     *
     * @since Stud.IP 4.4
     */
    public static function boolArray($param)
    {
        $array = self::getArray($param);

        foreach ($array as $key => $value) {
            $array[$key] = (bool) $value;
        }

        return $array;
    }

    /**
     * Return the value of the selected query parameter as an array of
     * strings consisting only of allowed characters for usernames.
     *
     * @param string $param    parameter name
     *
     * @return array  parameter value as array (if set), else an empty array
     */
    public static function usernameArray($param)
    {
        $array = self::getArray($param);

        foreach ($array as $key => $value) {
            if (!preg_match(Config::get()->USERNAME_REGULAR_EXPRESSION, $value)) {
                unset($array[$key]);
            }
        }

        return $array;
    }
    /**
     * Check whether a form submit button has been pressed. This works for
     * both image and text submit buttons.
     *
     * @param string $param    submit button name
     *
     * @returns boolean  true if the button has been submitted, else false
     */
    public static function submitted($param)
    {
        $request = self::getInstance();

        return isset($request[$param])
            || isset($request[$param . '_x']);
    }

    /**
     * Check whether one of the form submit buttons has been
     * pressed. This works for both image and text submit buttons.
     *
     * @param string ...
     *                 a variable argument list of submit button names
     *
     * @returns boolean  true if any button has been submitted, else false
     */
    public static function submittedSome($param/*, ... */)
    {
        foreach(func_get_args() as $button) {
            if (self::submitted($button)) {
                return TRUE;
            }
        }
        return FALSE;
    }

    /**
     * Returns the (uppercase) request method.
     *
     * @return string  the uppercased method of the request
     */
    public static function method()
    {
        return mb_strtoupper($_SERVER['X_HTTP_METHOD_OVERRIDE'] ?? $_SERVER['REQUEST_METHOD']);
    }

    /**
     * @return boolean  true if this a GET request
     */
    public static function isGet()
    {
        return self::method() === 'GET';
    }

    /**
     * @return boolean  true if this a POST request
     */
    public static function isPost()
    {
        return self::method() === 'POST';
    }

    /**
     * @return boolean  true if this a PUT request
     */
    public static function isPut()
    {
        return self::method() === 'PUT';
    }

    /**
     * @return boolean  true if this a DELETE request
     */
    public static function isDelete()
    {
        return self::method() === 'DELETE';
    }


    /**
     * @return boolean  true if this an XmlHttpRequest sent by jQuery/prototype
     */
    public static function isXhr()
    {
        return isset($_SERVER['HTTP_X_REQUESTED_WITH']) &&
            strcasecmp($_SERVER['HTTP_X_REQUESTED_WITH'], 'xmlhttprequest') === 0;
    }

    /**
     * This is an alias of Request::isXhr
     *
     * @return boolean  true if this an XmlHttpRequest sent by jQuery/prototype
     */
    public static function isAjax()
    {
        return self::isXhr();
    }

    /**
     * extracts some params from request, the desired params must be a comma separated list
     * for each param, the type of used extraction method can be specified after the name,
     * default is get
     * null values are not returned
     *
     * e.g.:
     * $data = Request::extract('admission_prelim int, admission_binding submitted, admission_prelim_txt');
     * will yield
     * array(3) {
     *    ["admission_prelim"]=>
     *    int(1)
     *    ["admission_binding"]=>
     *    bool(false)
     *    ["admission_prelim_txt"]=>
     *    string(0) ""
     *  }
     * @param string $what comma separated list of param names and types
     * @return array assoc array with extracted data
     */
    public static function extract($what)
    {
        $extract = [];
        $return = [];
        foreach (explode(',', $what) as $one) {
            $extract[] = array_values(array_filter(array_map('trim', explode(' ', $one))));
        }
        foreach ($extract as $one) {
            $param = $one[0];
            $func = $one[1] ?? 'get';

            $value = self::$func($param);
            if ($value !== null) {
                $return[$param] = $value;
            }
        }
        return $return;
    }

    /**
     * returns true if http header indicates that the response will be rendered as dialog
     *
     * @return bool
     */
    public static function isDialog()
    {
        return self::isXhr() && isset($_SERVER['HTTP_X_DIALOG']);
    }

    /**
     * Returns an object that has previously been serialized using the
     * ObjectBuilder.
     *
     * @param String $param         parameter name
     * @param mixed $expected_class Expected class name of object (optional)
     * @param bool   $allow_null    If true, return null on error; otherwise an
     *                              exception is thrown
     * @return mixed Object of arbitrary type or null on error and $allow_null
     * @throws Exception when an error occurs and $allow_null = false
     * @see ObjectBuilder
     */
    public static function getObject($param, $expected_class = null, $allow_null = true)
    {
        try {
            return ObjectBuilder::build(Request::get($param), $expected_class);
        } catch (Exception $e) {
            if ($allow_null) {
                return null;
            }

            throw $e;
        }
    }

    /**
     * Returns a collection of objects that have previously been serialized
     * using the ObjectBuilder.
     *
     * @param String $param         parameter name
     * @param mixed $expected_class Expected class name of objects (optional)
     * @param bool   $allow_null    If true, return empty array on error;
     *                              otherwise an exception is thrown
     * @return array as collection of objects
     * @throws Exception when an error occurs and $allow_null = false
     * @see ObjectBuilder
     */
    public static function getManyObjects($param, $expected_class = null, $allow_null = true)
    {
        try {
            $request = self::getInstance();
            return ObjectBuilder::buildMany($request[$param] ?? null, $expected_class);
        } catch (Exception $e) {
            if ($allow_null) {
                return [];
            }

            throw $e;
        }
    }
}