<?php

namespace Sabre\HTTP;

use DateTime;

/**
 * A collection of useful helpers for parsing or generating various HTTP
 * headers.
 *
 * @copyright Copyright (C) fruux GmbH (https://fruux.com/)
 * @author Evert Pot (http://evertpot.com/)
 * @license http://sabre.io/license/ Modified BSD License
 */

/**
 * Parses a HTTP date-string.
 *
 * This method returns false if the date is invalid.
 *
 * The following formats are supported:
 *    Sun, 06 Nov 1994 08:49:37 GMT    ; IMF-fixdate
 *    Sunday, 06-Nov-94 08:49:37 GMT   ; obsolete RFC 850 format
 *    Sun Nov  6 08:49:37 1994         ; ANSI C's asctime() format
 *
 * See:
 *   http://tools.ietf.org/html/rfc7231#section-7.1.1.1
 *
 * @param string $dateString
 * @return bool|DateTime
 */
function parseDate($dateString) {

    // Only the format is checked, valid ranges are checked by strtotime below
    $month = '(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)';
    $weekday = '(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)';
    $wkday = '(Mon|Tue|Wed|Thu|Fri|Sat|Sun)';
    $time = '([0-1]\d|2[0-3])(\:[0-5]\d){2}';
    $date3 = $month . ' ([12]\d|3[01]| [1-9])';
    $date2 = '(0[1-9]|[12]\d|3[01])\-' . $month . '\-\d{2}';
    // 4-digit year cannot begin with 0 - unix timestamp begins in 1970
    $date1 = '(0[1-9]|[12]\d|3[01]) ' . $month . ' [1-9]\d{3}';

    // ANSI C's asctime() format
    // 4-digit year cannot begin with 0 - unix timestamp begins in 1970
    $asctime_date = $wkday . ' ' . $date3 . ' ' . $time . ' [1-9]\d{3}';
    // RFC 850, obsoleted by RFC 1036
    $rfc850_date = $weekday . ', ' . $date2 . ' ' . $time . ' GMT';
    // RFC 822, updated by RFC 1123
    $rfc1123_date = $wkday . ', ' . $date1 . ' ' . $time . ' GMT';
    // allowed date formats by RFC 2616
    $HTTP_date = "($rfc1123_date|$rfc850_date|$asctime_date)";

    // allow for space around the string and strip it
    $dateString = trim($dateString, ' ');
    if (!preg_match('/^' . $HTTP_date . '$/', $dateString))
        return false;

    // append implicit GMT timezone to ANSI C time format
    if (strpos($dateString, ' GMT') === false)
        $dateString .= ' GMT';

    try {
        return new DateTime($dateString, new \DateTimeZone('UTC'));
    } catch (\Exception $e) {
        return false;
    }

}

/**
 * Transforms a DateTime object to a valid HTTP/1.1 Date header value
 *
 * @param DateTime $dateTime
 * @return string
 */
function toDate(DateTime $dateTime) {

    // We need to clone it, as we don't want to affect the existing
    // DateTime.
    $dateTime = clone $dateTime;
    $dateTime->setTimezone(new \DateTimeZone('GMT'));
    return $dateTime->format('D, d M Y H:i:s \G\M\T');

}

/**
 * This function can be used to aid with content negotiation.
 *
 * It takes 2 arguments, the $acceptHeaderValue, which usually comes from
 * an Accept header, and $availableOptions, which contains an array of
 * items that the server can support.
 *
 * The result of this function will be the 'best possible option'. If no
 * best possible option could be found, null is returned.
 *
 * When it's null you can according to the spec either return a default, or
 * you can choose to emit 406 Not Acceptable.
 *
 * The method also accepts sending 'null' for the $acceptHeaderValue,
 * implying that no accept header was sent.
 *
 * @param string|null $acceptHeaderValue
 * @param array $availableOptions
 * @return string|null
 */
function negotiateContentType($acceptHeaderValue, array $availableOptions) {

    if (!$acceptHeaderValue) {
        // Grabbing the first in the list.
        return reset($availableOptions);
    }

    $proposals = array_map(
        'Sabre\HTTP\parseMimeType',
        explode(',', $acceptHeaderValue)
    );

    // Ensuring array keys are reset.
    $availableOptions = array_values($availableOptions);

    $options = array_map(
        'Sabre\HTTP\parseMimeType',
        $availableOptions
    );

    $lastQuality = 0;
    $lastSpecificity = 0;
    $lastOptionIndex = 0;
    $lastChoice = null;

    foreach ($proposals as $proposal) {

        // Ignoring broken values.
        if (is_null($proposal)) continue;

        // If the quality is lower we don't have to bother comparing.
        if ($proposal['quality'] < $lastQuality) {
            continue;
        }

        foreach ($options as $optionIndex => $option) {

            if ($proposal['type'] !== '*' && $proposal['type'] !== $option['type']) {
                // no match on type.
                continue;
            }
            if ($proposal['subType'] !== '*' && $proposal['subType'] !== $option['subType']) {
                // no match on subtype.
                continue;
            }

            // Any parameters appearing on the options must appear on
            // proposals.
            foreach ($option['parameters'] as $paramName => $paramValue) {
                if (!array_key_exists($paramName, $proposal['parameters'])) {
                    continue 2;
                }
                if ($paramValue !== $proposal['parameters'][$paramName]) {
                    continue 2;
                }
            }

            // If we got here, we have a match on parameters, type and
            // subtype. We need to calculate a score for how specific the
            // match was.
            $specificity =
                ($proposal['type'] !== '*' ? 20 : 0) +
                ($proposal['subType'] !== '*' ? 10 : 0) +
                count($option['parameters']);


            // Does this entry win?
            if (
                ($proposal['quality'] > $lastQuality) ||
                ($proposal['quality'] === $lastQuality && $specificity > $lastSpecificity) ||
                ($proposal['quality'] === $lastQuality && $specificity === $lastSpecificity && $optionIndex < $lastOptionIndex)
            ) {

                $lastQuality = $proposal['quality'];
                $lastSpecificity = $specificity;
                $lastOptionIndex = $optionIndex;
                $lastChoice = $availableOptions[$optionIndex];

            }

        }

    }

    return $lastChoice;

}

/**
 * Parses the Prefer header, as defined in RFC7240.
 *
 * Input can be given as a single header value (string) or multiple headers
 * (array of string).
 *
 * This method will return a key->value array with the various Prefer
 * parameters.
 *
 * Prefer: return=minimal will result in:
 *
 * [ 'return' => 'minimal' ]
 *
 * Prefer: foo, wait=10 will result in:
 *
 * [ 'foo' => true, 'wait' => '10']
 *
 * This method also supports the formats from older drafts of RFC7240, and
 * it will automatically map them to the new values, as the older values
 * are still pretty common.
 *
 * Parameters are currently discarded. There's no known prefer value that
 * uses them.
 *
 * @param string|string[] $input
 * @return array
 */
function parsePrefer($input) {

    $token = '[!#$%&\'*+\-.^_`~A-Za-z0-9]+';

    // Work in progress
    $word = '(?: [a-zA-Z0-9]+ | "[a-zA-Z0-9]*" )';

    $regex = <<<REGEX
/
^
(?<name> $token)      # Prefer property name
\s*                   # Optional space
(?: = \s*             # Prefer property value
   (?<value> $word)
)?
(?: \s* ; (?: .*))?   # Prefer parameters (ignored)
$
/x
REGEX;

    $output = [];
    foreach (getHeaderValues($input) as $value) {

        if (!preg_match($regex, $value, $matches)) {
            // Ignore
            continue;
        }

        // Mapping old values to their new counterparts
        switch ($matches['name']) {
            case 'return-asynch' :
                $output['respond-async'] = true;
                break;
            case 'return-representation' :
                $output['return'] = 'representation';
                break;
            case 'return-minimal' :
                $output['return'] = 'minimal';
                break;
            case 'strict' :
                $output['handling'] = 'strict';
                break;
            case 'lenient' :
                $output['handling'] = 'lenient';
                break;
            default :
                if (isset($matches['value'])) {
                    $value = trim($matches['value'], '"');
                } else {
                    $value = true;
                }
                $output[strtolower($matches['name'])] = empty($value) ? true : $value;
                break;
        }

    }

    return $output;

}

/**
 * This method splits up headers into all their individual values.
 *
 * A HTTP header may have more than one header, such as this:
 *   Cache-Control: private, no-store
 *
 * Header values are always split with a comma.
 *
 * You can pass either a string, or an array. The resulting value is always
 * an array with each spliced value.
 *
 * If the second headers argument is set, this value will simply be merged
 * in. This makes it quicker to merge an old list of values with a new set.
 *
 * @param string|string[] $values
 * @param string|string[] $values2
 * @return string[]
 */
function getHeaderValues($values, $values2 = null) {

    $values = (array)$values;
    if ($values2) {
        $values = array_merge($values, (array)$values2);
    }
    foreach ($values as $l1) {
        foreach (explode(',', $l1) as $l2) {
            $result[] = trim($l2);
        }
    }
    return $result;

}

/**
 * Parses a mime-type and splits it into:
 *
 * 1. type
 * 2. subtype
 * 3. quality
 * 4. parameters
 *
 * @param string $str
 * @return array
 */
function parseMimeType($str) {

    $parameters = [];
    // If no q= parameter appears, then quality = 1.
    $quality = 1;

    $parts = explode(';', $str);

    // The first part is the mime-type.
    $mimeType = array_shift($parts);

    $mimeType = explode('/', trim($mimeType));
    if (count($mimeType) !== 2) {
        // Illegal value
        return null;
    }
    list($type, $subType) = $mimeType;

    foreach ($parts as $part) {

        $part = trim($part);
        if (strpos($part, '=')) {
            list($partName, $partValue) =
                explode('=', $part, 2);
        } else {
            $partName = $part;
            $partValue = null;
        }

        // The quality parameter, if it appears, also marks the end of
        // the parameter list. Anything after the q= counts as an
        // 'accept extension' and could introduce new semantics in
        // content-negotation.
        if ($partName !== 'q') {
            $parameters[$partName] = $part;
        } else {
            $quality = (float)$partValue;
            break; // Stop parsing parts
        }

    }

    return [
        'type'       => $type,
        'subType'    => $subType,
        'quality'    => $quality,
        'parameters' => $parameters,
    ];

}

/**
 * Encodes the path of a url.
 *
 * slashes (/) are treated as path-separators.
 *
 * @param string $path
 * @return string
 */
function encodePath($path) {

    return preg_replace_callback('/([^A-Za-z0-9_\-\.~\(\)\/:@])/', function($match) {

        return '%' . sprintf('%02x', ord($match[0]));

    }, $path);

}

/**
 * Encodes a 1 segment of a path
 *
 * Slashes are considered part of the name, and are encoded as %2f
 *
 * @param string $pathSegment
 * @return string
 */
function encodePathSegment($pathSegment) {

    return preg_replace_callback('/([^A-Za-z0-9_\-\.~\(\):@])/', function($match) {

        return '%' . sprintf('%02x', ord($match[0]));

    }, $pathSegment);
}

/**
 * Decodes a url-encoded path
 *
 * @param string $path
 * @return string
 */
function decodePath($path) {

    return decodePathSegment($path);

}

/**
 * Decodes a url-encoded path segment
 *
 * @param string $path
 * @return string
 */
function decodePathSegment($path) {

    $path = rawurldecode($path);
    $encoding = mb_detect_encoding($path, ['UTF-8', 'ISO-8859-1']);

    switch ($encoding) {

        case 'ISO-8859-1' :
            $path = utf8_encode($path);

    }

    return $path;

}
