<?php
/**
* @package   Warp Theme Framework
* @author    YOOtheme http://www.yootheme.com
* @copyright Copyright (C) YOOtheme GmbH
* @license   http://www.gnu.org/licenses/gpl.html GNU/GPL
*/

namespace Warp\Http\Transport;

/**
 * HTTP transport base class.
 * Based on HTTP Socket connection class (http://cakephp.org, Cake Software Foundation, Inc., MIT License)
 */
class AbstractTransport
{
    /**
     * Request defaults.
     * 
     * @var array
     */
    protected $request = array(
        'method' => 'GET',
        'version' => '1.1',
        'timeout' => 5,
        'redirects' => 5,
        'line' => null,
        'file' => null,
        'header' => array('Connection' => 'close', 'User-Agent' => 'Warp'),
        'body' => '',
        'cookies' => array(),
        'auth' => array('method' => 'Basic', 'user' => null, 'pass' => null),
        'raw' => null
    );

    /**
     * Response defaults.
     * 
     * @var array
     */
    protected $response = array(
        'header' => array(),
        'body' => '',
        'cookies' => array(),
        'status' => array('http-version' => null, 'code' => null, 'reason-phrase' => null),
        'raw' => array('status-line' => null, 'header' => null, 'body' => null, 'response' => null)
    );

    /**
     * @var string
     */
    protected $line_break = "\r\n";

    /**
     * Builds cookie headers for a request.
     * 
     * @param array $cookies
     * 
     * @return string         
     */
    public function buildCookies($cookies)
    {
        $header = array();
        foreach ($cookies as $name => $cookie) {
            $header[] = $name.'='.$this->escapeToken($cookie['value'], array(';'));
        }
        $header = $this->buildHeader(array('Cookie' => $header), 'pragmatic');
        return $header;
    }

    /**
     * Parses cookies in response headers.
     * 
     * @param array $header
     * 
     * @return array        
     */
    public function parseCookies($header)
    {
        if (!isset($header['Set-Cookie'])) {
            return false;
        }

        $cookies = array();
        foreach ((array) $header['Set-Cookie'] as $cookie) {
            if (strpos($cookie, '";"') !== false) {
                $cookie = str_replace('";"', "{__cookie_replace__}", $cookie);
                $parts  = str_replace("{__cookie_replace__}", '";"', explode(';', $cookie));
            } else {
                $parts = preg_split('/\;[ \t]*/', $cookie);
            }

            list($name, $value) = explode('=', array_shift($parts), 2);
            $cookies[$name] = compact('value');

            foreach ($parts as $part) {
                if (strpos($part, '=') !== false) {
                    list($key, $value) = explode('=', $part);
                } else {
                    $key = $part;
                    $value = true;
                }

                $key = strtolower($key);
                if (!isset($cookies[$name][$key])) {
                    $cookies[$name][$key] = $value;
                }
            }
        }

        return $cookies;
    }

    /**
     * Parses the given http request url and options to build the http request string.
     * 
     * @param string $url    
     * @param array  $options
     * 
     * @return array         
     */
    protected function parseRequest($url, $options = array())
    {
        $request = array_merge($this->request, array('url' => $this->parseUrl($url)), $options);

        $request['timeout']   = (int) ceil($request['timeout']);
        $request['redirects'] = (int) $request['redirects'];

        if (is_array($request['header'])) {
            $request['header'] = $this->parseHeader($request['header']);
            $request['header'] = array_merge(array('Host' => $request['url']['host']), $request['header']);
        }

        if (isset($request['auth']['user']) && isset($request['auth']['pass'])) {
            $request['header']['Authorization'] = $request['auth']['method'].' '.base64_encode($request['auth']['user'].':'.$request['auth']['pass']);
        }

        if (isset($request['url']['user']) && isset($request['url']['pass'])) {
            $request['header']['Authorization'] = $request['auth']['method'].' '.base64_encode($request['url']['user'].':'.$request['url']['pass']);
        }

        if (!empty($request['body']) && !isset($request['header']['Content-Type'])) {
            $request['header']['Content-Type'] = 'application/x-www-form-urlencoded';
        }

        if (!empty($request['body']) && !isset($request['header']['Content-Length'])) {
            $request['header']['Content-Length'] = strlen($request['body']);
        }

        if (empty($request['line'])) {
            $request['line'] = strtoupper($request['method']).' '.$request['url']['path'].(isset($request['url']['query']) ? '?'.$request['url']['query'] : ''). ' HTTP/' . $request['version'].$this->line_break;
        }

        $request['raw'] = $request['line'].$this->buildHeader($request['header']);

        if (!empty($request['cookies'])) {
            $request['raw'] .= $this->buildCookies($request['cookies']);
        }

        $request['raw'] .= $this->line_break.$request['body'];

        return $request;
    }

    /**
     * Parses the given http response and breaks it down in parts.
     * 
     * @param string $res
     * 
     * @return array
     */
    protected function parseResponse($res)
    {
        // set defaults
        $response = $this->response;
        $response['raw']['response'] = $res;

        // parse header
        if (preg_match("/^(.+\r\n)(.*)(?<=\r\n)\r\n/Us", $res, $match)) {

            list($null, $response['raw']['status-line'], $response['raw']['header']) = $match;
            $response['raw']['body'] = substr($res, strlen($match[0]));

            if (preg_match("/(.+) ([0-9]{3}) (.+)\r\n/DU", $response['raw']['status-line'], $match)) {
                $response['status']['http-version'] = $match[1];
                $response['status']['code'] = (int) $match[2];
                $response['status']['reason-phrase'] = $match[3];
            }

            $response['header'] = $this->parseHeader($response['raw']['header']);
            $response['body']   = $response['raw']['body'];

            if (!empty($response['header'])) {
                $response['cookies'] = $this->parseCookies($response['header']);
            }

        } else {
            $response['body'] = $res;
            $response['raw']['body'] = $res;
        }

        if (isset($response['header']['Transfer-Encoding']) && $response['header']['Transfer-Encoding'] == 'chunked') {
            $response['body'] = $this->decodeChunkedBody($response['body']);
        }

        foreach ($response['raw'] as $field => $val) {
            if ($val === '') {
                $response['raw'][$field] = null;
            }
        }

        return $response;
    }

    /**
     * Builds the header string for a request.
     * 
     * @param mixed  $header
     * @param string $mode
     * 
     * @return string
     */
    protected function buildHeader($header, $mode = 'standard')
    {
        if (is_string($header)) {
            return $header;
        } elseif (!is_array($header)) {
            return false;
        }

        $returnHeader = '';
        foreach ($header as $field => $contents) {

            if (is_array($contents) && $mode == 'standard') {
                $contents = implode(',', $contents);
            }

            foreach ((array) $contents as $content) {
                $contents = preg_replace("/\r\n(?![\t ])/", "\r\n ", $content);
                $field = $this->escapeToken($field);
                $returnHeader .= $field.': '.$contents.$this->line_break;
            }
        }

        return $returnHeader;
    }

    /**
     * Parses an string based header to an array.
     * 
     * @param mixed $header
     * 
     * @return array
     */
    protected function parseHeader($header)
    {
        if (is_array($header)) {
            foreach ($header as $field => $value) {
                unset($header[$field]);
                $field = strtolower($field);
                preg_match_all('/(?:^|(?<=-))[a-z]/U', $field, $offsets, PREG_OFFSET_CAPTURE);

                foreach ($offsets[0] as $offset) {
                    $field = substr_replace($field, strtoupper($offset[0]), $offset[1], 1);
                }
                $header[$field] = $value;
            }
            return $header;
        } elseif (!is_string($header)) {
            return false;
        }

        preg_match_all("/(.+):(.+)(?:(?<![\t ])" . $this->line_break . "|\$)/Uis", $header, $matches, PREG_SET_ORDER);

        $header = array();
        foreach ($matches as $match) {
            list(, $field, $value) = $match;

            $value = trim($value);
            $value = preg_replace("/[\t ]\r\n/", "\r\n", $value);

            $field = $this->unescapeToken($field);

            $field = strtolower($field);
            preg_match_all('/(?:^|(?<=-))[a-z]/U', $field, $offsets, PREG_OFFSET_CAPTURE);
            foreach ($offsets[0] as $offset) {
                $field = substr_replace($field, strtoupper($offset[0]), $offset[1], 1);
            }

            if (!isset($header[$field])) {
                $header[$field] = $value;
            } else {
                $header[$field] = array_merge((array) $header[$field], (array) $value);
            }
        }

        return $header;
    }

    /**
     * Decodes a chunked message $body
     * 
     * @param string $body
     * 
     * @return string
     */
    protected function decodeChunkedBody($body)
    {
        if (!is_string($body)) {
            return false;
        }

        $decodedBody = null;
        $chunkLength = null;

        while ($chunkLength !== 0) {

            // body is not chunked or is malformed
            if (!preg_match("/^([0-9a-f]+) *(?:;(.+)=(.+))?\r\n/iU", $body, $match)) {
                return $body;
            }

            $chunkSize = 0;
            $hexLength = 0;
            $chunkExtensionName = '';
            $chunkExtensionValue = '';
            if (isset($match[0])) {
                $chunkSize = $match[0];
            }
            if (isset($match[1])) {
                $hexLength = $match[1];
            }
            if (isset($match[2])) {
                $chunkExtensionName = $match[2];
            }
            if (isset($match[3])) {
                $chunkExtensionValue = $match[3];
            }

            $body = substr($body, strlen($chunkSize));
            $chunkLength = hexdec($hexLength);
            $chunk = substr($body, 0, $chunkLength);
            $decodedBody .= $chunk;

            if ($chunkLength !== 0) {
                $body = substr($body, $chunkLength + strlen("\r\n"));
            }
        }

        return $decodedBody;
    }

    /**
     * Parse a URL and return its components as array.
     * 
     * @param string $url
     * 
     * @return array     
     */
    protected function parseUrl($url)
    {
        // parse url
        $url = array_merge(array('user' => null, 'pass' => null, 'path' => '/', 'query' => null, 'fragment' => null), parse_url($url));

        // set scheme
        if (!isset($url['scheme'])) {
            $url['scheme'] = 'http';
        }

        // set host
        if (!isset($url['host'])) {
            $url['host'] = $_SERVER['SERVER_NAME'];
        }

        // set port
        if (!isset($url['port'])) {
            $url['port'] = $url['scheme'] == 'https' ? 443 : 80;
        }

        // set path
        if (!isset($url['path'])) {
            $url['path'] = '/';
        }

        return $url;
    }

    /**
     * Escapes a given $token according to RFC 2616 (HTTP 1.1 specs)
     * 
     * @param string     $token
     * @param array|null $chars
     * 
     * @return string
     */
    protected function escapeToken($token, $chars = null)
    {
        $regex = '/(['.join('', $this->tokenEscapeChars(true, $chars)).'])/';
        $token = preg_replace($regex, '"\\1"', $token);
        return $token;
    }

    /**
     * Unescapes a given $token according to RFC 2616 (HTTP 1.1 specs)
     * 
     * @param string     $token
     * @param array|null $chars
     * 
     * @return string
     */
    protected function unescapeToken($token, $chars = null)
    {
        $regex = '/"(['.join('', $this->tokenEscapeChars(true, $chars)).'])"/';
        $token = preg_replace($regex, '\\1', $token);
        return $token;
    }

    /**
     * Gets escape chars according to RFC 2616 (HTTP 1.1 specs)
     * 
     * @param boolean    $hex  
     * @param array|null $chars
     * 
     * @return array
     */
    protected function tokenEscapeChars($hex = true, $chars = null)
    {
        if (!empty($chars)) {
            $escape = $chars;
        } else {
            $escape = array('"', "(", ")", "<", ">", "@", ",", ";", ":", "\\", "/", "[", "]", "?", "=", "{", "}", " ");
            for ($i = 0; $i <= 31; $i++) {
                $escape[] = chr($i);
            }
            $escape[] = chr(127);
        }

        if ($hex == false) {
            return $escape;
        }

        $regexChars = '';

        foreach ($escape as $key => $char) {
            $escape[$key] = '\\x'.str_pad(dechex(ord($char)), 2, '0', STR_PAD_LEFT);
        }

        return $escape;
    }
}
