<?php

/*
 * This file is part of the symfony package.
 * (c) 2004-2006 Fabien Potencier <fabien.potencier@symfony-project.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

/**
 * sfWebResponse class.
 *
 * This class manages web reponses. It supports cookies and headers management.
 *
 * @package    symfony
 * @subpackage response
 * @author     Fabien Potencier <fabien.potencier@symfony-project.com>
 * @version    SVN: $Id: sfWebResponse.class.php 24619 2009-11-30 23:14:18Z FabianLange $
 */
class sfWebResponse extends sfResponse
{
  const
    FIRST  = 'first',
    MIDDLE = '',
    LAST   = 'last',
    ALL    = 'ALL',
    RAW    = 'RAW';

  protected
    $cookies     = array(),
    $statusCode  = 200,
    $statusText  = 'OK',
    $headerOnly  = false,
    $headers     = array(),
    $metas       = array(),
    $httpMetas   = array(),
    $positions   = array('first', '', 'last'),
    $stylesheets = array(),
    $javascripts = array(),
    $slots       = array();

  static protected $statusTexts = array(
    '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',
    '306' => '(Unused)',
    '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',
  );

  /**
   * Initializes this sfWebResponse.
   *
   * Available options:
   *
   *  * charset:           The charset to use (utf-8 by default)
   *  * content_type:      The content type (text/html by default)
   *  * send_http_headers: Whether to send HTTP headers or not (true by default)
   *  * http_protocol:     The HTTP protocol to use for the response (HTTP/1.0 by default)
   *
   * @param  sfEventDispatcher $dispatcher  An sfEventDispatcher instance
   * @param  array             $options     An array of options
   *
   * @return bool true, if initialization completes successfully, otherwise false
   *
   * @throws <b>sfInitializationException</b> If an error occurs while initializing this sfResponse
   *
   * @see sfResponse
   */
  public function initialize(sfEventDispatcher $dispatcher, $options = array())
  {
    parent::initialize($dispatcher, $options);

    $this->javascripts = array_combine($this->positions, array_fill(0, count($this->positions), array()));
    $this->stylesheets = array_combine($this->positions, array_fill(0, count($this->positions), array()));

    if (!isset($this->options['charset']))
    {
      $this->options['charset'] = 'utf-8';
    }

    if (!isset($this->options['send_http_headers']))
    {
      $this->options['send_http_headers'] = true;
    }

    if (!isset($this->options['http_protocol']))
    {
      $this->options['http_protocol'] = 'HTTP/1.0';
    }

    $this->options['content_type'] = $this->fixContentType(isset($this->options['content_type']) ? $this->options['content_type'] : 'text/html');
  }

  /**
   * Sets if the response consist of just HTTP headers.
   *
   * @param bool $value
   */
  public function setHeaderOnly($value = true)
  {
    $this->headerOnly = (boolean) $value;
  }

  /**
   * Returns if the response must only consist of HTTP headers.
   *
   * @return bool returns true if, false otherwise
   */
  public function isHeaderOnly()
  {
    return $this->headerOnly;
  }

  /**
   * Sets a cookie.
   *
   * @param  string  $name      HTTP header name
   * @param  string  $value     Value for the cookie
   * @param  string  $expire    Cookie expiration period
   * @param  string  $path      Path
   * @param  string  $domain    Domain name
   * @param  bool    $secure    If secure
   * @param  bool    $httpOnly  If uses only HTTP
   *
   * @throws <b>sfException</b> If fails to set the cookie
   */
  public function setCookie($name, $value, $expire = null, $path = '/', $domain = '', $secure = false, $httpOnly = false)
  {
    if ($expire !== null)
    {
      if (is_numeric($expire))
      {
        $expire = (int) $expire;
      }
      else
      {
        $expire = strtotime($expire);
        if ($expire === false || $expire == -1)
        {
          throw new sfException('Your expire parameter is not valid.');
        }
      }
    }

    $this->cookies[$name] = array(
      'name'     => $name,
      'value'    => $value,
      'expire'   => $expire,
      'path'     => $path,
      'domain'   => $domain,
      'secure'   => $secure ? true : false,
      'httpOnly' => $httpOnly,
    );
  }

  /**
   * Sets response status code.
   *
   * @param string $code  HTTP status code
   * @param string $name  HTTP status text
   *
   */
  public function setStatusCode($code, $name = null)
  {
    $this->statusCode = $code;
    $this->statusText = null !== $name ? $name : self::$statusTexts[$code];
  }

  /**
   * Retrieves status text for the current web response.
   *
   * @return string Status text
   */
  public function getStatusText()
  {
    return $this->statusText;
  }

  /**
   * Retrieves status code for the current web response.
   *
   * @return integer Status code
   */
  public function getStatusCode()
  {
    return $this->statusCode;
  }

  /**
   * Sets a HTTP header.
   *
   * @param string  $name     HTTP header name
   * @param string  $value    Value (if null, remove the HTTP header)
   * @param bool    $replace  Replace for the value
   *
   */
  public function setHttpHeader($name, $value, $replace = true)
  {
    $name = $this->normalizeHeaderName($name);

    if (null === $value)
    {
      unset($this->headers[$name]);

      return;
    }

    if ('Content-Type' == $name)
    {
      if ($replace || !$this->getHttpHeader('Content-Type', null))
      {
        $this->setContentType($value);
      }

      return;
    }

    if (!$replace)
    {
      $current = isset($this->headers[$name]) ? $this->headers[$name] : '';
      $value = ($current ? $current.', ' : '').$value;
    }

    $this->headers[$name] = $value;
  }

  /**
   * Gets HTTP header current value.
   *
   * @param  string $name     HTTP header name
   * @param  string $default  Default value returned if named HTTP header is not found
   *
   * @return string
   */
  public function getHttpHeader($name, $default = null)
  {
    $name = $this->normalizeHeaderName($name);

    return isset($this->headers[$name]) ? $this->headers[$name] : $default;
  }

  /**
   * Checks if response has given HTTP header.
   *
   * @param  string $name  HTTP header name
   *
   * @return bool
   */
  public function hasHttpHeader($name)
  {
    return array_key_exists($this->normalizeHeaderName($name), $this->headers);
  }

  /**
   * Sets response content type.
   *
   * @param string $value  Content type
   *
   */
  public function setContentType($value)
  {
    $this->headers['Content-Type'] = $this->fixContentType($value);
  }

  /**
   * Gets the current charset as defined by the content type.
   *
   * @return string The current charset
   */
  public function getCharset()
  {
    return $this->options['charset'];
  }

  /**
   * Gets response content type.
   *
   * @return array
   */
  public function getContentType()
  {
    return $this->getHttpHeader('Content-Type', $this->options['content_type']);
  }

  /**
   * Sends HTTP headers and cookies. Only the first invocation of this method will send the headers.
   * Subsequent invocations will silently do nothing. This allows certain actions to send headers early,
   * while still using the standard controller.
   */
  public function sendHttpHeaders()
  {
    if (!$this->options['send_http_headers'])
    {
      return;
    }

    // status
    $status = $this->options['http_protocol'].' '.$this->statusCode.' '.$this->statusText;
    header($status);

    if (substr(php_sapi_name(), 0, 3) == 'cgi')
    {
      // fastcgi servers cannot send this status information because it was sent by them already due to the HTT/1.0 line
      // so we can safely unset them. see ticket #3191
      unset($this->headers['Status']);
    }

    if ($this->options['logging'])
    {
      $this->dispatcher->notify(new sfEvent($this, 'application.log', array(sprintf('Send status "%s"', $status))));
    }

    // headers
    if (!$this->getHttpHeader('Content-Type'))
    {
      $this->setContentType($this->options['content_type']);
    }
    foreach ($this->headers as $name => $value)
    {
      header($name.': '.$value);

      if ($value != '' && $this->options['logging'])
      {
        $this->dispatcher->notify(new sfEvent($this, 'application.log', array(sprintf('Send header "%s: %s"', $name, $value))));
      }
    }

    // cookies
    foreach ($this->cookies as $cookie)
    {
      setrawcookie($cookie['name'], $cookie['value'], $cookie['expire'], $cookie['path'], $cookie['domain'], $cookie['secure'], $cookie['httpOnly']);

      if ($this->options['logging'])
      {
        $this->dispatcher->notify(new sfEvent($this, 'application.log', array(sprintf('Send cookie "%s": "%s"', $cookie['name'], $cookie['value']))));
      }
    }
    // prevent resending the headers
    $this->options['send_http_headers'] = false;
  }

  /**
   * Send content for the current web response.
   *
   */
  public function sendContent()
  {
    if (!$this->headerOnly)
    {
      parent::sendContent();
    }
  }

  /**
   * Sends the HTTP headers and the content.
   */
  public function send()
  {
    $this->sendHttpHeaders();
    $this->sendContent();
  }

  /**
   * Retrieves a normalized Header.
   *
   * @param  string $name  Header name
   *
   * @return string Normalized header
   */
  protected function normalizeHeaderName($name)
  {
    return preg_replace('/\-(.)/e', "'-'.strtoupper('\\1')", strtr(ucfirst(strtolower($name)), '_', '-'));
  }

  /**
   * Retrieves a formated date.
   *
   * @param  string $timestamp  Timestamp
   * @param  string $type       Format type
   *
   * @return string Formatted date
   */
  static public function getDate($timestamp, $type = 'rfc1123')
  {
    $type = strtolower($type);

    if ($type == 'rfc1123')
    {
      return substr(gmdate('r', $timestamp), 0, -5).'GMT';
    }
    else if ($type == 'rfc1036')
    {
      return gmdate('l, d-M-y H:i:s ', $timestamp).'GMT';
    }
    else if ($type == 'asctime')
    {
      return gmdate('D M j H:i:s', $timestamp);
    }
    else
    {
      throw new InvalidArgumentException('The second getDate() method parameter must be one of: rfc1123, rfc1036 or asctime.');
    }
  }

  /**
   * Adds vary to a http header.
   *
   * @param string $header  HTTP header
   */
  public function addVaryHttpHeader($header)
  {
    $vary = $this->getHttpHeader('Vary');
    $currentHeaders = array();
    if ($vary)
    {
      $currentHeaders = preg_split('/\s*,\s*/', $vary);
    }
    $header = $this->normalizeHeaderName($header);

    if (!in_array($header, $currentHeaders))
    {
      $currentHeaders[] = $header;
      $this->setHttpHeader('Vary', implode(', ', $currentHeaders));
    }
  }

  /**
   * Adds an control cache http header.
   *
   * @param string $name   HTTP header
   * @param string $value  Value for the http header
   */
  public function addCacheControlHttpHeader($name, $value = null)
  {
    $cacheControl = $this->getHttpHeader('Cache-Control');
    $currentHeaders = array();
    if ($cacheControl)
    {
      foreach (preg_split('/\s*,\s*/', $cacheControl) as $tmp)
      {
        $tmp = explode('=', $tmp);
        $currentHeaders[$tmp[0]] = isset($tmp[1]) ? $tmp[1] : null;
      }
    }
    $currentHeaders[strtr(strtolower($name), '_', '-')] = $value;

    $headers = array();
    foreach ($currentHeaders as $key => $value)
    {
      $headers[] = $key.(null !== $value ? '='.$value : '');
    }

    $this->setHttpHeader('Cache-Control', implode(', ', $headers));
  }

  /**
   * Retrieves meta headers for the current web response.
   *
   * @return string Meta headers
   */
  public function getHttpMetas()
  {
    return $this->httpMetas;
  }

  /**
   * Adds a HTTP meta header.
   *
   * @param string  $key      Key to replace
   * @param string  $value    HTTP meta header value (if null, remove the HTTP meta)
   * @param bool    $replace  Replace or not
   */
  public function addHttpMeta($key, $value, $replace = true)
  {
    $key = $this->normalizeHeaderName($key);

    // set HTTP header
    $this->setHttpHeader($key, $value, $replace);

    if (null === $value)
    {
      unset($this->httpMetas[$key]);

      return;
    }

    if ('Content-Type' == $key)
    {
      $value = $this->getContentType();
    }
    elseif (!$replace)
    {
      $current = isset($this->httpMetas[$key]) ? $this->httpMetas[$key] : '';
      $value = ($current ? $current.', ' : '').$value;
    }

    $this->httpMetas[$key] = $value;
  }

  /**
   * Retrieves all meta headers.
   *
   * @return array List of meta headers
   */
  public function getMetas()
  {
    return $this->metas;
  }

  /**
   * Adds a meta header.
   *
   * @param string  $key      Name of the header
   * @param string  $value    Meta header value (if null, remove the meta)
   * @param bool    $replace  true if it's replaceable
   * @param bool    $escape   true for escaping the header
   */
  public function addMeta($key, $value, $replace = true, $escape = true)
  {
    $key = strtolower($key);

    if (null === $value)
    {
      unset($this->metas[$key]);

      return;
    }

    // FIXME: If you use the i18n layer and escape the data here, it won't work
    // see include_metas() in AssetHelper
    if ($escape)
    {
      $value = htmlspecialchars($value, ENT_QUOTES, $this->options['charset']);
    }

    $current = isset($this->metas[$key]) ? $this->metas[$key] : null;
    if ($replace || !$current)
    {
      $this->metas[$key] = $value;
    }
  }

  /**
   * Retrieves title for the current web response.
   *
   * @return string Title
   */
  public function getTitle()
  {
    return isset($this->metas['title']) ? $this->metas['title'] : '';
  }

  /**
   * Sets title for the current web response.
   *
   * @param string  $title   Title name
   * @param bool    $escape  true, for escaping the title
   */
  public function setTitle($title, $escape = true)
  {
    $this->addMeta('title', $title, true, $escape);
  }

  /**
   * Returns the available position names for stylesheets and javascripts in order.
   *
   * @return array An array of position names
   */
  public function getPositions()
  {
    return $this->positions;
  }

  /**
   * Retrieves stylesheets for the current web response.
   *
   * By default, the position is sfWebResponse::ALL,
   * and the method returns all stylesheets ordered by position.
   *
   * @param  string  $position The position
   *
   * @return array   An associative array of stylesheet files as keys and options as values
   */
  public function getStylesheets($position = self::ALL)
  {
    if (self::ALL === $position)
    {
      $stylesheets = array();
      foreach ($this->getPositions() as $position)
      {
        foreach ($this->stylesheets[$position] as $file => $options)
        {
          $stylesheets[$file] = $options;
        }
      }

      return $stylesheets;
    }
    else if (self::RAW === $position)
    {
      return $this->stylesheets;
    }

    $this->validatePosition($position);

    return $this->stylesheets[$position];
  }

  /**
   * Adds a stylesheet to the current web response.
   *
   * @param string $file      The stylesheet file
   * @param string $position  Position
   * @param string $options   Stylesheet options
   */
  public function addStylesheet($file, $position = '', $options = array())
  {
    $this->validatePosition($position);

    $this->stylesheets[$position][$file] = $options;
  }

  /**
   * Removes a stylesheet from the current web response.
   *
   * @param string $file The stylesheet file to remove
   */
  public function removeStylesheet($file)
  {
    foreach ($this->getPositions() as $position)
    {
      unset($this->stylesheets[$position][$file]);
    }
  }

  /**
   * Retrieves javascript files from the current web response.
   *
   * By default, the position is sfWebResponse::ALL,
   * and the method returns all javascripts ordered by position.
   *
   * @param  string $position  The position
   *
   * @return array An associative array of javascript files as keys and options as values
   */
  public function getJavascripts($position = self::ALL)
  {
    if (self::ALL === $position)
    {
      $javascripts = array();
      foreach ($this->getPositions() as $position)
      {
        foreach ($this->javascripts[$position] as $file => $options)
        {
          $javascripts[$file] = $options;
        }
      }

      return $javascripts;
    }
    else if (self::RAW === $position)
    {
      return $this->javascripts;
    }

    $this->validatePosition($position);

    return $this->javascripts[$position];
  }

  /**
   * Adds javascript code to the current web response.
   *
   * @param string $file      The JavaScript file
   * @param string $position  Position
   * @param string $options   Javascript options
   */
  public function addJavascript($file, $position = '', $options = array())
  {
    $this->validatePosition($position);

    $this->javascripts[$position][$file] = $options;
  }

  /**
   * Removes a JavaScript file from the current web response.
   *
   * @param string $file The Javascript file to remove
   */
  public function removeJavascript($file)
  {
    foreach ($this->getPositions() as $position)
    {
      unset($this->javascripts[$position][$file]);
    }
  }

  /**
   * Retrieves slots from the current web response.
   *
   * @return string Javascript code
   */
  public function getSlots()
  {
    return $this->slots;
  }

  /**
   * Sets a slot content.
   *
   * @param string $name     Slot name
   * @param string $content  Content
   */
  public function setSlot($name, $content)
  {
    $this->slots[$name] = $content;
  }

  /**
   * Retrieves cookies from the current web response.
   *
   * @return array Cookies
   */
  public function getCookies()
  {
    return $this->cookies;
  }

  /**
   * Retrieves HTTP headers from the current web response.
   *
   * @return string HTTP headers
   */
  public function getHttpHeaders()
  {
    return $this->headers;
  }

  /**
   * Cleans HTTP headers from the current web response.
   */
  public function clearHttpHeaders()
  {
    $this->headers = array();
  }

  /**
   * Copies all properties from a given sfWebResponse object to the current one.
   *
   * @param sfWebResponse $response  An sfWebResponse instance
   */
  public function copyProperties(sfWebResponse $response)
  {
    $this->options     = $response->getOptions();
    $this->headers     = $response->getHttpHeaders();
    $this->metas       = $response->getMetas();
    $this->httpMetas   = $response->getHttpMetas();
    $this->stylesheets = $response->getStylesheets(self::RAW);
    $this->javascripts = $response->getJavascripts(self::RAW);
    $this->slots       = $response->getSlots();
  }

  /**
   * Merges all properties from a given sfWebResponse object to the current one.
   *
   * @param sfWebResponse $response  An sfWebResponse instance
   */
  public function merge(sfWebResponse $response)
  {
    foreach ($this->getPositions() as $position)
    {
      $this->javascripts[$position] = array_merge($this->getJavascripts($position), $response->getJavascripts($position));
      $this->stylesheets[$position] = array_merge($this->getStylesheets($position), $response->getStylesheets($position));
    }

    $this->slots = array_merge($this->getSlots(), $response->getSlots());
  }

  /**
   * @see sfResponse
   */
  public function serialize()
  {
    return serialize(array($this->content, $this->statusCode, $this->statusText, $this->options, $this->cookies, $this->headerOnly, $this->headers, $this->metas, $this->httpMetas, $this->stylesheets, $this->javascripts, $this->slots));
  }

  /**
   * @see sfResponse
   */
  public function unserialize($serialized)
  {
    list($this->content, $this->statusCode, $this->statusText, $this->options, $this->cookies, $this->headerOnly, $this->headers, $this->metas, $this->httpMetas, $this->stylesheets, $this->javascripts, $this->slots) = unserialize($serialized);
  }

  /**
   * Validate a position name.
   *
   * @param  string $position
   *
   * @throws InvalidArgumentException if the position is not available
   */
  protected function validatePosition($position)
  {
    if (!in_array($position, $this->positions, true))
    {
      throw new InvalidArgumentException(sprintf('The position "%s" does not exist (available positions: %s).', $position, implode(', ', $this->positions)));
    }
  }

  /**
   * Fixes the content type by adding the charset for text content types.
   *
   * @param  string $contentType  The content type
   *
   * @return string The content type with the charset if needed
   */
  protected function fixContentType($contentType)
  {
    // add charset if needed (only on text content)
    if (false === stripos($contentType, 'charset') && (0 === stripos($contentType, 'text/') || strlen($contentType) - 3 === strripos($contentType, 'xml')))
    {
      $contentType .= '; charset='.$this->options['charset'];
    }

    // change the charset for the response
    if (preg_match('/charset\s*=\s*(.+)\s*$/', $contentType, $match))
    {
      $this->options['charset'] = $match[1];
    }

    return $contentType;
  }
}
