<?php

/**
 * HTTP_Accept class for dealing with the HTTP 'Accept' header
 * 
 * PHP versions 4 and 5
 *
 * LICENSE:  This source file is subject to the MIT License.
 * The full text of this license is available at the following URL:
 * http://www.opensource.org/licenses/mit-license.php
 *
 * @category    HTTP
 * @package     HTTP_Accept
 * @author      Kevin Locke <kwl7@cornell.edu>
 * @copyright   2007 Kevin Locke
 * @license     http://www.opensource.org/licenses/mit-license.php MIT License
 * @version     SVN: $Id: HTTP_Accept.php 22 2007-10-06 18:46:45Z kevin $
 * @link        http://pear.php.net/package/HTTP_Accept
 */

/**
 * HTTP_Accept class for dealing with the HTTP 'Accept' header
 *
 * This class is intended to be used to parse the HTTP Accept header into
 * usable information and provide a simple API for dealing with that
 * information.
 *
 * The parsing of this class is designed to follow RFC 2616 to the letter,
 * any deviations from that standard are bugs and should be reported to the
 * maintainer.
 *
 * Often the class will be used very simply as
 * <code>
 * <?php
 * $accept = new HTTP_Accept($_SERVER['HTTP_ACCEPT']);
 * if ($accept->getQuality("image/png") > $accept->getQuality("image/jpeg"))
 *     // Send PNG image
 * else
 *     // Send JPEG image
 * ?>
 * </code>
 *
 * However, for browsers which do not accurately describe their preferences,
 * it may be necessary to check if a MIME Type is explicitly listed in their
 * Accept header, in addition to being preferred to another type
 *
 * <code>
 * <?php
 *  if ($accept->isMatchExact("application/xhtml+xml"))
 *      // Client specifically asked for this type at some quality level
 * ?>
 * </code>
 *
 * 
 * @category    HTTP
 * @package     HTTP_Accept
 * @access      public
 * @link        http://pear.php.net/package/HTTP_Accept
 */
class HTTP_Accept
{
    /**
     * Array of types and their associated parameters, extensions, and quality
     * factors, as represented in the Accept: header.
     * Indexed by [type][subtype][index],
     * and contains 'PARAMS', 'QUALITY', and 'EXTENSIONS' keys for the
     * parameter set, quality factor, and extensions set respectively.
     * Note:  Since type, subtype, and parameters are case-insensitive
     * (RFC 2045 5.1) they are stored as lower-case.
     * 
     * @var     array
     * @access  private
     */
    var $acceptedtypes = array();

    /**
     * Regular expression to match a token, as defined in RFC 2045
     * 
     * @var     string
     * @access  private
     */
    var $_matchtoken = '(?:[^[:cntrl:]()<>@,;:\\\\"\/\[\]?={} \t]+)';

    /**
     * Regular expression to match a quoted string, as defined in RFC 2045
     * 
     * @var     string
     * @access  private
     */
    var $_matchqstring = '(?:"[^\\\\"]*(?:\\\\.[^\\\\"]*)*")';

    /**
     * Constructs a new HTTP_Accept object
     *
     * Initializes the HTTP_Accept class with a given accept string
     * or creates a new (empty) HTTP_Accept object if no string is given
     *
     * Note:  The behavior is a little strange here to accomodate
     * missing headers (to be interpreted as accept all) as well as
     * new empty objects which should accept nothing.  This means that
     * HTTP_Accept("") will be different than HTTP_Accept()
     * 
     * @access  public
     * @return  object  HTTP_Accept
     * @param   string  $acceptstring The value of an Accept: header
     *                                Will often be $_SERVER['HTTP_ACCEPT']
     *                                Note:  If get_magic_quotes_gpc is on,
     *                                run stripslashes() on the string first
     */
    function HTTP_Accept()
    {
        if (func_num_args() == 0) {
            // User wishes to create empty HTTP_Accept object
            $this->acceptedtypes = array(
                    '*' => array(
                        '*' => array (
                            0 => array(
                                       'PARAMS' => array(),
                                       'QUALITY' => 0,
                                       'EXTENSIONS' => array()
                                )
                            )
                        )
                    );
            return;
        }

        $acceptstring = trim(func_get_arg(0));
        if (empty($acceptstring)) {
            // Accept header empty or not sent, interpret as "*/*"
            $this->acceptedtypes = array(
                    '*' => array(
                        '*' => array (
                            0 => array(
                                       'PARAMS' => array(),
                                       'QUALITY' => 1,
                                       'EXTENSIONS' => array()
                                )
                            )
                        )
                    );
            return;
        }

        $matches = preg_match_all(
                    '/\s*('.$this->_matchtoken.')\/' .          // typegroup/
                    '('.$this->_matchtoken.')' .                // subtype
                    '((?:\s*;\s*'.$this->_matchtoken.'\s*' .    // parameter
                    '(?:=\s*' .                                 // optional =value
                    '(?:'.$this->_matchqstring.'|'.$this->_matchtoken.'))?)*)/',        // value
                                  $acceptstring, $acceptedtypes,
                                  PREG_SET_ORDER);
         
        if ($matches == 0) {
            // Malformed Accept header
            $this->acceptedtypes = array(
                    '*' => array(
                        '*' => array (
                            0 => array(
                                       'PARAMS' => array(),
                                       'QUALITY' => 1,
                                       'EXTENSIONS' => array()
                                )
                            )
                        )
                    );
            return;
        }

        foreach ($acceptedtypes as $accepted) {
            $typefamily = strtolower($accepted[1]);
            $subtype = strtolower($accepted[2]);

            // */subtype is invalid according to grammar in section 14.1
            // so we ignore it
            if ($typefamily == '*' && $subtype != '*')
                continue;

            // Parse all arguments of the form "key=value"
            $matches = preg_match_all('/;\s*('.$this->_matchtoken.')\s*' .
                                      '(?:=\s*' .
                                      '('.$this->_matchqstring.'|'.
                                      $this->_matchtoken.'))?/',
                                      $accepted[3], $args,
                                      PREG_SET_ORDER);

            $params = array();
            $quality = -1;
            $extensions = array();
            foreach ($args as $arg) {
                array_shift($arg);
                if (!empty($arg[1])) {
                    // Strip quotes (Note:  Can't use trim() in case "text\"")
                    $len = strlen($arg[1]);
                    if ($arg[1][0] == '"' && $arg[1][$len-1] == '"'
                        && $len > 1) {
                        $arg[1] = substr($arg[1], 1, $len-2);
                        $arg[1] = stripslashes($arg[1]);
                    }
                } else if (!isset($arg[1])) {
                    $arg[1] = null;
                }

                // Everything before q=# is a parameter, after is an extension
                if ($quality >= 0) {
                    $extensions[$arg[0]] = $arg[1];
                } else if ($arg[0] == 'q') {
                    $quality = (float)$arg[1];

                    if ($quality < 0)
                        $quality = 0;
                    else if ($quality > 1)
                        $quality = 1;
                } else {
                    $arg[0] = strtolower($arg[0]);
                    // Values required for parameters,
                    // assume empty-string for missing values
                    if (isset($arg[1]))
                               $params[$arg[0]] = $arg[1];
                    else
                        $params[$arg[0]] = "";
                }
            }

            if ($quality < 0)
                $quality = 1;
            else if ($quality == 0)
                continue;

            if (!isset($this->acceptedtypes[$typefamily]))
                $this->acceptedtypes[$typefamily] = array();
            if (!isset($this->acceptedtypes[$typefamily][$subtype]))
                $this->acceptedtypes[$typefamily][$subtype] = array();

            $this->acceptedtypes[$typefamily][$subtype][] =
                       array('PARAMS' => $params,
                             'QUALITY' => $quality,
                             'EXTENSIONS' => $extensions);
        }

        if (!isset($this->acceptedtypes['*']))
            $this->acceptedtypes['*'] = array();
        if (!isset($this->acceptedtypes['*']['*']))
            $this->acceptedtypes['*']['*'] = array(
                    0 => array(
                               'PARAMS' => array(),
                               'QUALITY' => 0,
                               'EXTENSIONS' => array()
                        )
                    );
    }
    
    /**
     * Gets the accepted quality factor for a given MIME Type
     *
     * Note:  If there are multiple best matches
     * (e.g. "text/html;level=4;charset=utf-8" matching both "text/html;level=4"
     * and "text/html;charset=utf-8"), it returns the lowest quality factor as
     * a conservative estimate.  Further, if the ambiguity is between parameters
     * and extensions (e.g. "text/html;level=4;q=1;ext=foo" matching both
     * "text/html;level=4" and "text/html;q=1;ext=foo") the parameters take
     * precidence.
     *
     * Usage Note:  If the quality factor for all supported media types is 0,
     * RFC 2616 specifies that applications SHOULD send an HTTP 406 (not
     * acceptable) response.
     *
     * @access  public
     * @return  double  the quality value for the given MIME Type
     *                  Quality values are in the range [0,1] where 0 means
     *                  "not accepted" and 1 is "perfect quality".
     * @param   string  $mimetype   The MIME Type to query ("text/html")
     * @param   array   $params     Parameters of Type to query ([level => 4])
     * @param   array   $extensions Extension parameters to query
     */
    function getQuality($mimetype, $params = array(), $extensions = array())
    {
        $type = explode("/", $mimetype);
        $supertype = strtolower($type[0]);
        $subtype = strtolower($type[1]);

        if ($params == null)
            $params = array();
        if ($extensions == null)
            $extensions = array();

        if (empty($this->acceptedtypes[$supertype])) {
            if ($supertype == '*')
                return 0;
            else
                return $this->getQuality("*/*", $params, $extensions);
        }

        if (empty($this->acceptedtypes[$supertype][$subtype])) {
            if ($subtype == '*')
                return $this->getQuality("*/*", $params, $extensions);
            else
                return $this->getQuality("$supertype/*", $params, $extensions);
        }

        $params = array_change_key_case($params, CASE_LOWER);

        $matches = $this->_findBestMatchIndices($supertype, $subtype,
                                         $params, $extensions);

        if (count($matches) == 0) {
            if ($subtype != '*')
                return $this->getQuality("$supertype/*", $params, $extensions);
            else if ($supertype != '*')
                return $this->getQuality("*/*", $params, $extensions);
            else
                return 0;
        }

        $minquality = 1;
        foreach ($matches as $match)
            if ($this->acceptedtypes[$supertype][$subtype][$match]['QUALITY'] < $minquality)
                $minquality = $this->acceptedtypes[$supertype][$subtype][$match]['QUALITY'];

        return $minquality;
    }

    /**
     * Determines if there is an exact match for the specified MIME Type
     *
     * @access  public
     * @return  boolean true if there is an exact match to the given
     *                  values, false otherwise.
     * @param   string  $mimetype   The MIME Type to query (e.g. "text/html")
     * @param   array   $params     Parameters of Type to query (e.g. [level => 4])
     * @param   array   $extensions Extension parameters to query
     */
    function isMatchExact($mimetype, $params = array(), $extensions = array())
    {
        $type = explode("/", $mimetype);
        $supertype = strtolower($type[0]);
        $subtype = strtolower($type[1]);

        if ($params == null)
            $params = array();
        if ($extensions == null)
            $extensions = array();

        return $this->_findExactMatchIndex($supertype, $subtype,
                                           $params, $extensions) >= 0;
    }

    /**
     * Gets a list of all MIME Types explicitly accepted, sorted by quality
     *
     * @access  public
     * @return  array   list of MIME Types explicitly accepted, sorted
     *                  in decreasing order of quality factor
     */
    function getTypes()
    {
        $qvalues = array();
        $types = array();
        foreach ($this->acceptedtypes as $typefamily => $subtypes) {
            if ($typefamily == '*')
                continue;

            foreach ($subtypes as $subtype => $variants) {
                if ($subtype == '*')
                    continue;

                $maxquality = 0;
                foreach ($variants as $variant)
                    if ($variant['QUALITY'] > $maxquality)
                        $maxquality = $variant['QUALITY'];

                if ($maxquality > 0) {
                    $qvalues[] = $maxquality;
                    $types[] = $typefamily.'/'.$subtype;
                }
            }
        }

        array_multisort($qvalues, SORT_DESC, SORT_NUMERIC,
                        $types, SORT_DESC, SORT_STRING);

        return $types;
    }

    /**
     * Gets the parameter sets for a given mime type, sorted by quality.
     * Only parameter sets where the extensions set is empty will be returned.
     *
     * @access  public
     * @return  array   list of sets of name=>value parameter pairs
     *                  in decreasing order of quality factor
     * @param   string  $mimetype   The MIME Type to query ("text/html")
     */
    function getParameterSets($mimetype)
    {
        $type = explode("/", $mimetype);
        $supertype = strtolower($type[0]);
        $subtype = strtolower($type[1]);

        if (!isset($this->acceptedtypes[$supertype])
            || !isset($this->acceptedtypes[$supertype][$subtype]))
            return array();

        $qvalues = array();
        $paramsets = array();
        foreach ($this->acceptedtypes[$supertype][$subtype] as $acceptedtype) {
            if (count($acceptedtype['EXTENSIONS']) == 0) {
                $qvalues[] = $acceptedtype['QUALITY'];
                $paramsets[] = $acceptedtype['PARAMS'];
            }
        }

        array_multisort($qvalues, SORT_DESC, SORT_NUMERIC,
                        $paramsets, SORT_DESC, SORT_STRING);

        return $paramsets;
    }

    /**
     * Gets the extension sets for a given mime type, sorted by quality.
     * Only extension sets where the parameter set is empty will be returned.
     *
     * @access  public
     * @return  array   list of sets of name=>value extension pairs
     *                  in decreasing order of quality factor
     * @param   string  $mimetype   The MIME Type to query ("text/html")
     */
    function getExtensionSets($mimetype)
    {
        $type = explode("/", $mimetype);
        $supertype = strtolower($type[0]);
        $subtype = strtolower($type[1]);

        if (!isset($this->acceptedtypes[$supertype])
            || !isset($this->acceptedtypes[$supertype][$subtype]))
            return array();

        $qvalues = array();
        $extensionsets = array();
        foreach ($this->acceptedtypes[$supertype][$subtype] as $acceptedtype) {
            if (count($acceptedtype['PARAMS']) == 0) {
                $qvalues[] = $acceptedtype['QUALITY'];
                $extensionsets[] = $acceptedtype['EXTENSIONS'];
            }
        }

        array_multisort($qvalues, SORT_DESC, SORT_NUMERIC,
                        $extensionsets, SORT_DESC, SORT_STRING);

        return $extensionsets;
    }

    /**
     * Adds a type to the set of accepted types
     *
     * @access  public
     * @param   string  $mimetype   The MIME Type to add (e.g. "text/html")
     * @param   double  $quality    The quality value for the given MIME Type
     *                              Quality values are in the range [0,1] where
     *                              0 means "not accepted" and 1 is
     *                              "perfect quality".
     * @param   array   $params     Parameters of the type to add (e.g. [level => 4])
     * @param   array   $extensions Extension parameters of the type to add
     */
    function addType($mimetype, $quality = 1,
                     $params = array(), $extensions = array())
    {
        $type = explode("/", $mimetype);
        $supertype = strtolower($type[0]);
        $subtype = strtolower($type[1]);

        if ($params == null)
            $params = array();
        if ($extensions == null)
            $extensions = array();

        $index = $this->_findExactMatchIndex($supertype, $subtype, $params, $extensions);

        if ($index >= 0) {
            $this->acceptedtypes[$supertype][$subtype][$index]['QUALITY'] = $quality;
        } else {
            if (!isset($this->acceptedtypes[$supertype]))
                $this->acceptedtypes[$supertype] = array();
            if (!isset($this->acceptedtypes[$supertype][$subtype]))
                $this->acceptedtypes[$supertype][$subtype] = array();

            $this->acceptedtypes[$supertype][$subtype][] =
                array('PARAMS' => $params,
                      'QUALITY' => $quality,
                      'EXTENSIONS' => $extensions);
        }
    }

    /**
     * Removes a type from the set of accepted types
     *
     * @access  public
     * @param   string  $mimetype   The MIME Type to remove (e.g. "text/html")
     * @param   array   $params     Parameters of the type to remove (e.g. [level => 4])
     * @param   array   $extensions Extension parameters of the type to remove
     */
    function removeType($mimetype, $params = array(), $extensions = array())
    {
        $type = explode("/", $mimetype);
        $supertype = strtolower($type[0]);
        $subtype = strtolower($type[1]);

        if ($params == null)
            $params = array();
        if ($extensions == null)
            $extensions = array();

        $index = $this->_findExactMatchIndex($supertype, $subtype, $params, $extensions);

        if ($index >= 0) {
            $this->acceptedtypes[$supertype][$subtype] =
                array_merge(array_slice($this->acceptedtypes[$supertype][$subtype],
                                        0, $index),
                            array_slice($this->acceptedtypes[$supertype][$subtype],
                                        $index+1));
        }
    }

    /**
     * Gets a string representation suitable for use in an HTTP Accept header
     *
     * @access  public
     * @return  string  a string representation of this object
     */
    function __toString()
    {
        $accepted = array();
        $qvalues = array();
        foreach ($this->acceptedtypes as $supertype => $subtypes) {
            foreach ($subtypes as $subtype => $entries) {
                foreach ($entries as $entry) {
                    $accepted[] = array('TYPE' => "$supertype/$subtype",
                            'QUALITY' => $entry['QUALITY'],
                            'PARAMS' => $entry['PARAMS'],
                            'EXTENSIONS' => $entry['EXTENSIONS']);
                    $qvalues[] = $entry['QUALITY'];
                }
            }
        }

        array_multisort($qvalues, SORT_DESC, SORT_NUMERIC,
                        $accepted);

        $str = "";
        foreach ($accepted as $accept) {
            // Skip the catchall value if it is 0, since this is implied
            if ($accept['TYPE'] == '*/*' &&
                $accept['QUALITY'] == 0 &&
                count($accept['PARAMS']) == 0 &&
                count($accept['EXTENSIONS'] == 0))
                continue;

            $str = $str.$accept['TYPE'].';';

            foreach ($accept['PARAMS'] as $param => $value) {
                if (preg_match('/^'.$this->_matchtoken.'$/', $value))
                    $str = $str.$param.'='.$value.';';
                else
                    $str = $str.$param.'="'.addcslashes($value,'"\\').'";';
            }

            if ($accept['QUALITY'] < 1 || !empty($accept['EXTENSIONS']))
                $str = $str.'q='.$accept['QUALITY'].';';

            foreach ($accept['EXTENSIONS'] as $extension => $value) {
                if (preg_match('/^'.$this->_matchtoken.'$/', $value))
                    $str = $str.$extension.'='.$value.';';
                else
                    $str = $str.$extension.'="'.addcslashes($value,'"\\').'";';
            }

            $str[strlen($str)-1] = ',';
        }

        return rtrim($str, ',');
    }

    /**
     * Finds the index of an exact match for the specified MIME Type
     *
     * @access  private
     * @return  int     the index of an exact match if found,
     *                  -1 otherwise
     * @param   string  $supertype  The general MIME Type to find (e.g. "text")
     * @param   string  $subtype    The MIME subtype to find (e.g. "html")
     * @param   array   $params     Parameters of Type to find ([level => 4])
     * @param   array   $extensions Extension parameters to find
     */
    function _findExactMatchIndex($supertype, $subtype, $params, $extensions)
    {
        if (empty($this->acceptedtypes[$supertype])
            || empty($this->acceptedtypes[$supertype][$subtype]))
            return -1;

        $params = array_change_key_case($params, CASE_LOWER);

        $parammatches = array();
        foreach ($this->acceptedtypes[$supertype][$subtype] as $index => $typematch)
            if ($typematch['PARAMS'] == $params
                && $typematch['EXTENSIONS'] == $extensions)
                return $index;

        return -1;
    }

    /**
     * Finds the indices of the best matches for the specified MIME Type
     *
     * A "match" in this context is an exact type match and no extraneous
     * matches for parameters or extensions (so the best match for
     * "text/html;level=4" may be "text/html" but not the other way around).
     *
     * "Best" is interpreted as the entries that match the most
     * parameters and extensions (the sum of the number of matches)
     *
     * @access  private
     * @return  array   an array of the indices of the best matches
     *                  (empty if no matches)
     * @param   string  $supertype  The general MIME Type to find (e.g. "text")
     * @param   string  $subtype    The MIME subtype to find (e.g. "html")
     * @param   array   $params     Parameters of Type to find ([level => 4])
     * @param   array   $extensions Extension parameters to find
     */
    function _findBestMatchIndices($supertype, $subtype, $params, $extensions)
    {
        $bestmatches = array();
        $bestlength = 0;

        if (empty($this->acceptedtypes[$supertype])
            || empty($this->acceptedtypes[$supertype][$subtype]))
            return $bestmatches;

        foreach ($this->acceptedtypes[$supertype][$subtype] as $index => $typematch) {
            if (count(array_diff_assoc($typematch['PARAMS'], $params)) == 0
                && count(array_diff_assoc($typematch['EXTENSIONS'],
                                          $extensions)) == 0) {
                $length = count($typematch['PARAMS'])
                          + count($typematch['EXTENSIONS']);

                if ($length > $bestlength) {
                    $bestmatches = array($index);
                    $bestlength = $length;
                } else if ($length == $bestlength) {
                    $bestmatches[] = $index;
                }
            }
        }

        return $bestmatches;
    }
}

// vim: set ts=4 sts=4 sw=4 et:
?>