<?php

/*
 * This file is part of the symfony package.
 * (c) 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.
 */

/**
 * sfBrowserBase is the base class for sfBrowser.
 *
 * It implements features that is independent from the symfony controllers.
 *
 * @package    symfony
 * @subpackage util
 * @author     Fabien Potencier <fabien.potencier@symfony-project.com>
 * @version    SVN: $Id: sfBrowserBase.class.php 27616 2010-02-06 11:14:40Z FabianLange $
 */
abstract class sfBrowserBase
{
  protected
    $hostname           = null,
    $remote             = null,
    $dom                = null,
    $stack              = array(),
    $stackPosition      = -1,
    $cookieJar          = array(),
    $fields             = array(),
    $files              = array(),
    $vars               = array(),
    $defaultServerArray = array(),
    $headers            = array(),
    $currentException   = null;

  /**
   * Class constructor.
   *
   * @param string $hostname  Hostname to browse
   * @param string $remote    Remote address to spook
   * @param array  $options   Options for sfBrowser
   *
   * @return void
   */
  public function __construct($hostname = null, $remote = null, $options = array())
  {
    $this->initialize($hostname, $remote, $options);
  }

  /**
   * Initializes sfBrowser - sets up environment
   *
   * @param string $hostname  Hostname to browse
   * @param string $remote    Remote address to spook
   * @param array  $options   Options for sfBrowser
   *
   * @return void
   */
  public function initialize($hostname = null, $remote = null, $options = array())
  {
    unset($_SERVER['argv']);
    unset($_SERVER['argc']);

    // setup our fake environment
    $this->hostname = is_null($hostname) ? 'localhost' : $hostname;
    $this->remote   = is_null($remote) ? '127.0.0.1' : $remote;

    // we set a session id (fake cookie / persistence)
    $this->newSession();

    // store default global $_SERVER array
    $this->defaultServerArray = $_SERVER;

    // register our shutdown function
    register_shutdown_function(array($this, 'shutdown'));
  }

  /**
   * Sets variable name
   *
   * @param string $name   The variable name
   * @param mixed  $value  The value
   *
   * @return sfBrowserBase
   */
  public function setVar($name, $value)
  {
    $this->vars[$name] = $value;

    return $this;
  }

  /**
   * Sets a HTTP header for the very next request.
   *
   * @param string $header  The header name
   * @param string $value   The header value
   */
  public function setHttpHeader($header, $value)
  {
    $this->headers[$header] = $value;

    return $this;
  }

  /**
   * Sets a cookie.
   *
   * @param  string  $name     The cookie 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
   * 
   * @return sfBrowserBase     This sfBrowserBase instance
   */
  public function setCookie($name, $value, $expire = null, $path = '/', $domain = '', $secure = false, $httpOnly = false)
  {
    $this->cookieJar[$name] = array(
      'name'     => $name,
      'value'    => $value,
      'expire'   => $expire,
      'path'     => $path,
      'domain'   => $domain,
      'secure'   => (Boolean) $secure,
      'httpOnly' => $httpOnly,
    );

    return $this;
  }

  /**
   * Removes a cookie by name.
   *
   * @param string $name   The cookie name
   *
   * @return sfBrowserBase This sfBrowserBase instance
   */
  public function removeCookie($name)
  {
    unset($this->cookieJar[$name]);

    return $this;
  }

  /**
   * Clears all cookies.
   *
   * @return sfBrowserBase This sfBrowserBase instance
   */
  public function clearCookies()
  {
    $this->cookieJar = array();

    return $this;
  }

  /**
   * Sets username and password for simulating http authentication.
   *
   * @param string $username  The username
   * @param string $password  The password
   *
   * @return sfBrowserBase
   */
  public function setAuth($username, $password)
  {
    $this->vars['PHP_AUTH_USER'] = $username;
    $this->vars['PHP_AUTH_PW']   = $password;

    return $this;
  }

  /**
   * Gets a uri.
   *
   * @param string $uri         The URI to fetch
   * @param array  $parameters  The Request parameters
   * @param bool   $changeStack  Change the browser history stack?
   *
   * @return sfBrowserBase
   */
  public function get($uri, $parameters = array(), $changeStack = true)
  {
    return $this->call($uri, 'get', $parameters, $changeStack);
  }

  /**
   * Posts a uri.
   *
   * @param string $uri         The URI to fetch
   * @param array  $parameters  The Request parameters
   * @param bool   $changeStack  Change the browser history stack?
   *
   * @return sfBrowserBase
   */
  public function post($uri, $parameters = array(), $changeStack = true)
  {
    return $this->call($uri, 'post', $parameters, $changeStack);
  }

  /**
   * Calls a request to a uri.
   *
   * @param string $uri          The URI to fetch
   * @param string $method       The request method
   * @param array  $parameters   The Request parameters
   * @param bool   $changeStack  Change the browser history stack?
   *
   * @return sfBrowserBase
   */
  public function call($uri, $method = 'get', $parameters = array(), $changeStack = true)
  {
    // check that the previous call() hasn't returned an uncatched exception
    $this->checkCurrentExceptionIsEmpty();

    $uri = $this->fixUri($uri);

    // add uri to the stack
    if ($changeStack)
    {
      $this->stack = array_slice($this->stack, 0, $this->stackPosition + 1);
      $this->stack[] = array(
        'uri'        => $uri,
        'method'     => $method,
        'parameters' => $parameters,
      );
      $this->stackPosition = count($this->stack) - 1;
    }

    list($path, $queryString) = false !== ($pos = strpos($uri, '?')) ? array(substr($uri, 0, $pos), substr($uri, $pos + 1)) : array($uri, '');
    $queryString = html_entity_decode($queryString);

    // remove anchor
    $path = preg_replace('/#.*/', '', $path);

    // removes all fields from previous request
    $this->fields = array();

    // prepare the request object
    $_SERVER = $this->defaultServerArray;
    $_SERVER['HTTP_HOST']       = $this->hostname;
    $_SERVER['SERVER_NAME']     = $_SERVER['HTTP_HOST'];
    $_SERVER['SERVER_PORT']     = 80;
    $_SERVER['HTTP_USER_AGENT'] = 'PHP5/CLI';
    $_SERVER['REMOTE_ADDR']     = $this->remote;
    $_SERVER['REQUEST_METHOD']  = strtoupper($method);
    $_SERVER['PATH_INFO']       = $path;
    $_SERVER['REQUEST_URI']     = '/index.php'.$uri;
    $_SERVER['SCRIPT_NAME']     = '/index.php';
    $_SERVER['SCRIPT_FILENAME'] = '/index.php';
    $_SERVER['QUERY_STRING']    = $queryString;

    if ($this->stackPosition >= 1)
    {
      $_SERVER['HTTP_REFERER'] = sprintf('http%s://%s%s', isset($this->defaultServerArray['HTTPS']) ? 's' : '', $this->hostname, $this->stack[$this->stackPosition - 1]['uri']);
    }

    foreach ($this->vars as $key => $value)
    {
      $_SERVER[strtoupper($key)] = $value;
    }

    foreach ($this->headers as $header => $value)
    {
      $_SERVER['HTTP_'.strtoupper(str_replace('-', '_', $header))] = $value;
    }
    $this->headers = array();

    // request parameters
    $_GET = $_POST = array();
    if (in_array(strtoupper($method), array('POST', 'DELETE', 'PUT')))
    {
      if (isset($parameters['_with_csrf']) && $parameters['_with_csrf'])
      {
        unset($parameters['_with_csrf']);
        $form = new sfForm();
        $parameters[$form->getCSRFFieldName()] = $form->getCSRFToken();
      }

      $_POST = $parameters;
    }
    if (strtoupper($method) == 'GET')
    {
      $_GET = $parameters;
    }

    // handle input type="file" fields
    $_FILES = array();
    if (count($this->files))
    {
      $_FILES = $this->files;
    }
    $this->files = array();

    parse_str($queryString, $qs);
    if (is_array($qs))
    {
      $_GET = array_merge($qs, $_GET);
    }

    // expire cookies
    $cookies = $this->cookieJar;
    foreach ($cookies as $name => $cookie)
    {
      if ($cookie['expire'] && $cookie['expire'] < time())
      {
        unset($this->cookieJar[$name]);
      }
    }

    // restore cookies
    $_COOKIE = array();
    foreach ($this->cookieJar as $name => $cookie)
    {
      $_COOKIE[$name] = $cookie['value'];
    }

    $this->doCall();

    $response = $this->getResponse();

    // save cookies
    foreach ($response->getCookies() as $name => $cookie)
    {
      // FIXME: deal with path, secure, ...
      $this->cookieJar[$name] = $cookie;
    }

    // support for the ETag header
    if ($etag = $response->getHttpHeader('Etag'))
    {
      $this->vars['HTTP_IF_NONE_MATCH'] = $etag;
    }
    else
    {
      unset($this->vars['HTTP_IF_NONE_MATCH']);
    }

    // support for the last modified header
    if ($lastModified = $response->getHttpHeader('Last-Modified'))
    {
      $this->vars['HTTP_IF_MODIFIED_SINCE'] = $lastModified;
    }
    else
    {
      unset($this->vars['HTTP_IF_MODIFIED_SINCE']);
    }

    // for HTML/XML content, create a DOM and sfDomCssSelector objects for the response content
    $this->dom = null;
    $this->domCssSelector = null;
    if (preg_match('/(x|ht)ml/i', $response->getContentType(), $matches))
    {
      $this->dom = new DomDocument('1.0', $response->getCharset());
      $this->dom->validateOnParse = true;
      if ('x' == $matches[1])
      {
        @$this->dom->loadXML($response->getContent());
      }
      else
      {
        @$this->dom->loadHTML($response->getContent());
      }
      $this->domCssSelector = new sfDomCssSelector($this->dom);
    }

    return $this;
  }

  /**
   * Calls a request to a uri.
   */
  abstract protected function doCall();

  /**
   * Go back in the browser history stack.
   *
   * @return sfBrowserBase
   */
  public function back()
  {
    if ($this->stackPosition < 1)
    {
      throw new LogicException('You are already on the first page.');
    }

    --$this->stackPosition;
    return $this->call($this->stack[$this->stackPosition]['uri'], $this->stack[$this->stackPosition]['method'], $this->stack[$this->stackPosition]['parameters'], false);
  }

  /**
   * Go forward in the browser history stack.
   *
   * @return sfBrowserBase
   */
  public function forward()
  {
    if ($this->stackPosition > count($this->stack) - 2)
    {
      throw new LogicException('You are already on the last page.');
    }

    ++$this->stackPosition;
    return $this->call($this->stack[$this->stackPosition]['uri'], $this->stack[$this->stackPosition]['method'], $this->stack[$this->stackPosition]['parameters'], false);
  }

  /**
   * Reload the current browser.
   *
   * @return sfBrowserBase
   */
  public function reload()
  {
    if (-1 == $this->stackPosition)
    {
      throw new LogicException('No page to reload.');
    }

    return $this->call($this->stack[$this->stackPosition]['uri'], $this->stack[$this->stackPosition]['method'], $this->stack[$this->stackPosition]['parameters'], false);
  }

  /**
   * Get response dom css selector.
   *
   * @return sfDomCssSelector
   */
  public function getResponseDomCssSelector()
  {
    if (is_null($this->dom))
    {
      throw new LogicException('The DOM is not accessible because the browser response content type is not HTML.');
    }

    return $this->domCssSelector;
  }

  /**
   * Get response dom.
   *
   * @return sfDomCssSelector
   */
  public function getResponseDom()
  {
    if (is_null($this->dom))
    {
      throw new LogicException('The DOM is not accessible because the browser response content type is not HTML.');
    }

    return $this->dom;
  }

  /**
   * Gets response.
   *
   * @return sfWebResponse
   */
  abstract public function getResponse();

  /**
   * Gets request.
   *
   * @return sfWebRequest
   */
  abstract public function getRequest();

  /**
   * Gets user.
   *
   * @return sfUser
   */
  abstract public function getUser();

  /**
   * Gets the current exception.
   *
   * @return Exception
   */
  public function getCurrentException()
  {
    return $this->currentException;
  }

  /**
   * Sets the current exception.
   *
   * @param Exception $exception An Exception instance
   */
  public function setCurrentException(Exception $exception)
  {
    $this->currentException = $exception;
  }

  /**
   * Resets the current exception.
   */
  public function resetCurrentException()
  {
    $this->currentException = null;
  }

  /**
   * Test for an uncaught exception.
   *
   * @return  boolean
   */
  public function checkCurrentExceptionIsEmpty()
  {
    return is_null($this->getCurrentException()) || $this->getCurrentException() instanceof sfError404Exception;
  }

  /**
   * Follow redirects?
   *
   * @throws sfException If request was not a redirect
   *
   * @return sfBrowserBase
   */
  public function followRedirect()
  {
    if (null === $this->getResponse()->getHttpHeader('Location'))
    {
      throw new LogicException('The request was not redirected.');
    }

    return $this->get($this->getResponse()->getHttpHeader('Location'));
  }

  /**
   * Sets a form field in the browser.
   *
   * @param string $name   The field name
   * @param string $value  The field value
   *
   * @return sfBrowserBase
   */
  public function setField($name, $value)
  {
    // as we don't know yet the form, just store name/value pairs
    $this->parseArgumentAsArray($name, $value, $this->fields);

    return $this;
  }

  /**
   * Simulates deselecting a checkbox or radiobutton.
   *
   * @param string  $name       The checkbox or radiobutton id, name or text
   *
   * @return sfBrowserBase
   *
   * @see    doSelect()
   */
  public function deselect($name)
  {
    $this->doSelect($name, false);

    return $this;
  }

  /**
   * Simulates selecting a checkbox or radiobutton.
   *
   * @param string  $name       The checkbox or radiobutton id, name or text
   *
   * @return sfBrowserBase
   *
   * @see    doSelect()
   */
  public function select($name)
  {
    $this->doSelect($name, true);

    return $this;
  }

  /**
   * Simulates selecting a checkbox or radiobutton.
   *
   * This method is called internally by the select() and deselect() methods.
   *
   * @param string  $name       The checkbox or radiobutton id, name or text
   * @param boolean $selected   If true the item will be selected
   *
   */
  public function doSelect($name, $selected)
  {
    $position = 0;
    $dom = $this->getResponseDom();

    if (!$dom)
    {
      throw new LogicException('Cannot select because there is no current page in the browser.');
    }

    $xpath = new DomXpath($dom);

    if ($element = $xpath->query(sprintf('//input[(@type="radio" or @type="checkbox") and (.="%s" or @id="%s" or @name="%s")]', $name, $name, $name))->item($position))
    {
      if ($selected)
      {
        if ($element->getAttribute('type') == 'radio')
        {
          //we need to deselect all other radio buttons with the same name
          foreach ($xpath->query(sprintf('//input[@type="radio" and @name="%s"]', $element->getAttribute('name'))) as $radio)
          {
            $radio->removeAttribute('checked');
          }
        }
        $element->setAttribute('checked', 'checked');
      }
      else
      {
        if ($element->getAttribute('type') == 'radio')
        {
          throw new InvalidArgumentException('Radiobutton cannot be deselected - Select another radiobutton to deselect the current.');
        }
        $element->removeAttribute('checked');
      }
    }
    else
    {
      throw new InvalidArgumentException(sprintf('Cannot find the "%s" checkbox or radiobutton.', $name));
    }
  }

  /**
   * Simulates a click on a link or button.
   *
   * @param string  $name       The link or button text
   * @param array   $arguments  The arguments to pass to the link
   * @param array   $options    An array of options
   *
   * @return sfBrowserBase
   *
   * @see    doClick()
   */
  public function click($name, $arguments = array(), $options = array())
  {
    list($uri, $method, $parameters) = $this->doClick($name, $arguments, $options);

    return $this->call($uri, $method, $parameters);
  }

  /**
   * Simulates a click on a link or button.
   *
   * This method is called internally by the click() method.
   *
   * Available options:
   *
   *  * position: The position of the linked to link if several ones have the same name
   *              (the first one is 1, not 0)
   *  * method:   The method to used instead of the form ones
   *              (useful when you need to click on a link that is converted to a form with JavaScript code)
   *
   * @param string  $name       The link or button text
   * @param array   $arguments  The arguments to pass to the link
   * @param array   $options    An array of options
   *
   * @return array An array composed of the URI, the method and the arguments to pass to the call() call
   */
  public function doClick($name, $arguments = array(), $options = array())
  {
    $position = isset($options['position']) ? $options['position'] - 1 : 0;

    $dom = $this->getResponseDom();

    if (!$dom)
    {
      throw new LogicException('Cannot click because there is no current page in the browser.');
    }

    $xpath = new DomXpath($dom);

    $method = strtolower(isset($options['method']) ? $options['method'] : 'get');

    $query  = sprintf('//a[.="%s"]', $name);
    $query .= sprintf('|//a/img[@alt="%s"]/ancestor::a', $name);
    $query .= sprintf('|//input[((@type="submit" or @type="button") and @value="%s") or (@type="image" and @alt="%s")]/ancestor::form', $name, $name);
    $query .= sprintf('|//button[.="%s" or @id="%s" or @name="%s"]/ancestor::form', $name, $name, $name);
    $list = $xpath->query($query);

    if (!$item = $list->item($position))
    {
      throw new InvalidArgumentException(sprintf('Cannot find the "%s" link or button.', $name));
    }

    if ('a' == $item->nodeName)
    {
      if (in_array($method, array('post', 'put', 'delete')))
      {
        if (isset($options['_with_csrf']) && $options['_with_csrf'])
        {
          $arguments['_with_csrf'] = true;
        }

        return array($item->getAttribute('href'), $method, $arguments);
      }
      else
      {
        return array($item->getAttribute('href'), 'get', $arguments);
      }
    }

    // form attributes
    $url = $item->getAttribute('action');
    if (!$url || '#' == $url)
    {
      $url = $this->stack[$this->stackPosition]['uri'];
    }
    $method = strtolower(isset($options['method']) ? $options['method'] : ($item->getAttribute('method') ? $item->getAttribute('method') : 'get'));

    // merge form default values and arguments
    $defaults = array();
    $arguments = sfToolkit::arrayDeepMerge($this->fields, $arguments);

    foreach ($xpath->query('descendant::input | descendant::textarea | descendant::select', $item) as $element)
    {
      $elementName = $element->getAttribute('name');
      $nodeName    = $element->nodeName;
      $value       = null;

      if ($nodeName == 'input' && ($element->getAttribute('type') == 'checkbox' || $element->getAttribute('type') == 'radio'))
      {
        if ($element->getAttribute('checked'))
        {
          $value = $element->hasAttribute('value') ? $element->getAttribute('value') : '1';
        }
      }
      else if ($nodeName == 'input' && $element->getAttribute('type') == 'file')
      {
        $filename = array_key_exists($elementName, $arguments) ? $arguments[$elementName] : sfToolkit::getArrayValueForPath($arguments, $elementName, '');

        if (is_readable($filename))
        {
          $fileError = UPLOAD_ERR_OK;
          $fileSize = filesize($filename);
        }
        else
        {
          $fileError = UPLOAD_ERR_NO_FILE;
          $fileSize = 0;
        }

        unset($arguments[$elementName]);

        $this->parseArgumentAsArray($elementName, array('name' => basename($filename), 'type' => '', 'tmp_name' => $filename, 'error' => $fileError, 'size' => $fileSize), $this->files);
      }
      else if (
        $nodeName == 'input'
        &&
        (($element->getAttribute('type') != 'submit' && $element->getAttribute('type') != 'button') || $element->getAttribute('value') == $name)
        &&
        ($element->getAttribute('type') != 'image' || $element->getAttribute('alt') == $name)
      )
      {
        $value = $element->getAttribute('value');
      }
      else if ($nodeName == 'textarea')
      {
        $value = '';
        foreach ($element->childNodes as $el)
        {
          $value .= $dom->saveXML($el);
        }
      }
      else if ($nodeName == 'select')
      {
        if ($multiple = $element->hasAttribute('multiple'))
        {
          $elementName = str_replace('[]', '', $elementName);
          $value = array();
        }
        else
        {
          $value = null;
        }

        $found = false;
        foreach ($xpath->query('descendant::option', $element) as $option)
        {
          if ($option->getAttribute('selected'))
          {
            $found = true;
            if ($multiple)
            {
              $value[] = $option->getAttribute('value');
            }
            else
            {
              $value = $option->getAttribute('value');
            }
          }
        }

        // if no option is selected and if it is a simple select box, take the first option as the value
        $option = $xpath->query('descendant::option', $element)->item(0);
        if (!$found && !$multiple && $option instanceof DOMElement)
        {
          $value = $option->getAttribute('value');
        }
      }

      if (null !== $value)
      {
        $this->parseArgumentAsArray($elementName, $value, $defaults);
      }
    }

    // create request parameters
    $arguments = sfToolkit::arrayDeepMerge($defaults, $arguments);
    if (in_array($method, array('post', 'put', 'delete')))
    {
      return array($url, $method, $arguments);
    }
    else
    {
      $queryString = http_build_query($arguments, null, '&');
      $sep = false === strpos($url, '?') ? '?' : '&';

      return array($url.($queryString ? $sep.$queryString : ''), 'get', array());
    }
  }

  /**
   * Parses arguments as array
   *
   * @param string $name   The argument name
   * @param string $value  The argument value
   * @param array  $vars
   */
  protected function parseArgumentAsArray($name, $value, &$vars)
  {
    if (false !== $pos = strpos($name, '['))
    {
      $var = &$vars;
      $tmps = array_filter(preg_split('/(\[ | \[\] | \])/x', $name), create_function('$s', 'return $s !== "";'));
      foreach ($tmps as $tmp)
      {
        $var = &$var[$tmp];
      }
      if ($var)
      {
        if (!is_array($var))
        {
          $var = array($var);
        }
        $var[] = $value;
      }
      else
      {
        $var = $value;
      }
    }
    else
    {
      $vars[$name] = $value;
    }
  }

  /**
   * Reset browser to original state
   *
   * @return sfBrowserBase
   */
  public function restart()
  {
    $this->newSession();
    $this->cookieJar     = array();
    $this->stack         = array();
    $this->fields        = array();
    $this->vars          = array();
    $this->dom           = null;
    $this->stackPosition = -1;

    return $this;
  }

  /**
   * Shutdown function to clean up and remove sessions
   *
   * @return void
   */
  public function shutdown()
  {
    $this->checkCurrentExceptionIsEmpty();
  }

  /**
   * Fixes uri removing # declarations and front controller.
   *
   * @param  string $uri  The URI to fix
   * @return string The fixed uri
   */
  public function fixUri($uri)
  {
    // remove absolute information if needed (to be able to do follow redirects, click on links, ...)
    if (0 === strpos($uri, 'http'))
    {
      // detect secure request
      if (0 === strpos($uri, 'https'))
      {
        $this->defaultServerArray['HTTPS'] = 'on';
      }
      else
      {
        unset($this->defaultServerArray['HTTPS']);
      }

      $uri = preg_replace('#^https?\://[^/]+/#', '/', $uri);
    }
    $uri = str_replace('/index.php', '', $uri);

    // # as a uri
    if ($uri && '#' == $uri[0])
    {
      $uri = $this->stack[$this->stackPosition]['uri'].$uri;
    }

    return $uri;
  }

  /**
   * Creates a new session in the browser.
   *
   * @return void
   */
  protected function newSession()
  {
    $this->defaultServerArray['session_id'] = $_SERVER['session_id'] = md5(uniqid(rand(), true));
  }
}
