<?php
/*
 * This file is part of the MODX Revolution package.
 *
 * Copyright (c) MODX, LLC
 *
 * For complete copyright and license information, see the COPYRIGHT and LICENSE
 * files found in the top-level directory of this distribution.
 *
*/

/**
 * REST Client service class with XML/JSON/QS support
 *
 * @package modx
 * @subpackage rest
 */
class modRest {
    /** @var modX $modx */
    public $modx;
    /** @var array $config */
    public $config = array();
    /** @var mixed $handle The cURL resource handle. */
    public $handle;

    /** @var object $response Response body. */
    public $response;
    /** @var object $headers Parsed response header object */
    public $headers;
    /** @var object $info Response info object */
    public $info;
    /** @var string $error Response error string. */
    public $error;
    /** @var string $url The URL to query */
    public $url;

    /**
     * The modRest constructor
     *
     * @param modX $modx A reference to the modX instance
     * @param array $config An array of configuration options
     */
	public function __construct(modX &$modx,array $config = array()) {
		$this->modx =& $modx;
		$this->config = array_merge(array(
            'addMethodParameter' => false,
            'baseUrl' => null,
            'curlOptions' => array(),
            'defaultParameters' => array(),
            'format' => null,
            'headers' => array(),
            'password' => null,
            'suppressSuffix' => false,
            'userAgent' => 'MODX RestClient/1.0.0',
            'username' => null,
        ),$config);
		$this->modx->getService('lexicon','modLexicon');
        if ($this->modx->lexicon) {
            $this->modx->lexicon->load('rest');
        }
	}

    /**
     * @param string $key
     * @param mixed $value
     */
    public function setOption($key, $value){
        $this->config[$key] = $value;
    }

    /**
     * @param string $key
     * @param mixed $default
     * @return mixed
     */
    public function getOption($key,$default = null) {
        return array_key_exists($key,$this->config) ? $this->config[$key] : $default;
    }

    /**
     * @param string $url
     * @param array $parameters
     * @param array $headers
     * @return RestClientResponse
     */
    public function get($url, $parameters=array(), $headers=array()){
        return $this->execute($url, 'GET', $parameters, $headers);
    }

    /**
     * @param string $url
     * @param array $parameters
     * @param array $headers
     * @return RestClientResponse
     */
    public function post($url, $parameters=array(), $headers=array()){
        return $this->execute($url, 'POST', $parameters, $headers);
    }

    /**
     * @param string $url
     * @param array $parameters
     * @param array $headers
     * @return RestClientResponse
     */
    public function put($url, $parameters=array(), $headers=array()){
        if (!empty($this->config['addMethodParameter'])) {
            $parameters['_method'] = "PUT";
        }
        return $this->execute($url,'PUT',$parameters, $headers);
    }

    /**
     * @param string $url
     * @param array $parameters
     * @param array $headers
     * @return RestClientResponse
     */
    public function delete($url, $parameters=array(), $headers=array()){
        if (!empty($this->config['addMethodParameter'])) {
            $parameters['_method'] = "DELETE";
        }
        return $this->execute($url,'DELETE', $parameters, $headers);
    }

    /**
     * @param string $url
     * @param string $method
     * @param array $parameters
     * @param array $headers
     * @return RestClientResponse
     */
    protected function execute($url, $method='GET', $parameters=array(), $headers=array()){
        $request = new RestClientRequest($this->modx,$this->config);
        if (!empty($headers['rootNode'])) {
            $request->setRootNode($headers['rootNode']);
        }
        return $request->execute($url,$method,$parameters,$headers);
    }
}

/**
 * Request class for handling REST requests
 *
 * @package modx
 * @subpackage rest
 */
class RestClientRequest {
    /** @var modX $modx */
    public $modx;
    /** @var array $config */
    public $config = array();
    /** @var string $url */
    public $url;
    /** @var string $method */
    public $method = 'GET';
    /** @var mixed $handle */
    public $handle;
    /** @var array $requestParameters */
    public $requestParameters = array();
    /** @var array $requestOptions */
    public $requestOptions = array();
    /** @var array $headers */
    public $headers = array();
    /** @var array $defaultRequestParameters */
    public $defaultRequestParameters = array();
    /** @var string $rootNode */
    public $rootNode = 'request';

    /**
     * The RestClientRequest constructor
     *
     * @param modX $modx A reference to the modX instance
     * @param array $config An array of configuration options
     */
    function __construct(modX &$modx,array $config = array()) {
        $this->modx =& $modx;
        $this->config = array_merge($this->config,$config);
        if (!empty($this->config['headers'])) {
            $this->setHeaders($this->config['headers']);
        }
        if (!empty($this->config['defaultParameters'])) {
            $this->defaultRequestParameters = $this->config['defaultParameters'];
        }
        $this->_setDefaultRequestOptions();
    }

    /**
     * @param string $key
     * @param mixed $value
     */
    public function setOption($key, $value){
        $this->config[$key] = $value;
    }

    /**
     * @param string $key
     * @param mixed $default
     * @return mixed
     */
    public function getOption($key,$default = null) {
        return array_key_exists($key,$this->config) ? $this->config[$key] : $default;
    }

    /**
     * Set the root node of the request. Only used for XML requests.
     * @param string $node
     */
    public function setRootNode($node) {
        $this->rootNode = $node;
    }

    /**
     * Set the request parameters for the request.
     * @param array $parameters
     */
    public function setRequestParameters(array $parameters) {
        $this->requestParameters = array_merge($this->defaultRequestParameters,$parameters);
    }

    /**
     * Set the HTTP headers on the request
     *
     * @param array $headers
     * @param bool $merge
     */
    public function setHeaders($headers = array(),$merge = false) {
        $this->headers = $merge ? array_merge($this->headers,$headers) : $headers;
    }

    /**
     * Execute the request, properly preparing it, setting the URL and sending the request via cURL
     *
     * @param string $path
     * @param string $method
     * @param array $parameters
     * @param array $headers
     * @return RestClientResponse
     */
    public function execute($path,$method = 'GET', $parameters = array(), $headers = array()) {
        $this->url = $path;
        $this->method = strtoupper($method);
        $this->setRequestParameters($parameters);
        if (!empty($headers)) $this->setHeaders($headers,true);

        $this->prepare();
        return $this->send();
    }

    /**
     * Prepare the request for sending
     */
    protected function prepare() {
        $this->prepareHandle();
        $this->prepareAuthentication();
        $this->prepareUrl();
        $this->preparePayload();
        $this->prepareHeaders();
        $this->prepareRequestOptions();
    }

    /**
     * Send the request over the wire
     * @return RestClientResponse
     */
    protected function send() {
        $this->modx->log(modX::LOG_LEVEL_INFO,'[Rest] Sending request to '.$this->url.' with parameters: '.print_r($this->requestParameters,true));
        curl_setopt_array($this->handle,$this->requestOptions);
        $result = curl_exec($this->handle);
        $headerSize = curl_getinfo($this->handle,CURLINFO_HEADER_SIZE);
        $response = new RestClientResponse($this->modx,$result,$headerSize,$this->config);
        $info = (object) curl_getinfo($this->handle,CURLINFO_HTTP_CODE);
        $response->setResponseInfo($info);
        $error = curl_error($this->handle);
        $response->setResponseError($error);
        curl_close($this->handle);
        return $response;
    }

    /**
     * Load the request handle
     * @return mixed
     */
    protected function prepareHandle() {
        $this->handle = curl_init();
        return $this->handle;
    }

    /**
     * Set any authentication options for this request
     */
    protected function prepareAuthentication() {
        $username = $this->getOption('username','');
        $password = $this->getOption('password','');
        if (!empty($username)) {
            $this->requestOptions[CURLOPT_USERPWD] = $username.(!empty($password) ? ':'.$password : '');
        }
    }

    /**
     * Set any HTTP headers and load them into the request options
     */
    protected function prepareHeaders() {
        if (!empty($this->headers)) {
            if (empty($this->requestOptions[CURLOPT_HTTPHEADER])) {
                $this->requestOptions[CURLOPT_HTTPHEADER] = array();
            }
            foreach ($this->headers as $key => $value) {
                $this->requestOptions[CURLOPT_HTTPHEADER][] = sprintf("%s: %s", $key, $value);
            }
        }
    }

    /**
     * Prepare the URL, prefixing the baseUrl if set, and setting the format suffix, if wanted
     * @return mixed
     */
    protected function prepareUrl() {
        $format = $this->getOption('format','json');
        $suppressSuffix = $this->getOption('suppressSuffix',false);
        if (!empty($format) && !$suppressSuffix) {
            $this->url .= '.'.$format;
        }

        if ($this->method != 'POST' && count($this->requestParameters)){
            $this->url .= strpos($this->url, '?') ? '&' : '?';
            $this->url .= $this->_formatQuery($this->requestParameters);
        }

        $baseUrl = $this->getOption('baseUrl',false);
        if (!empty($baseUrl)) {
            if ((!empty($this->url) && $this->url[0] != '/') || substr($baseUrl, -1) != '/') {
                $this->url = '/' . $this->url;
            }
            $this->url = $baseUrl . $this->url;
        }
        $this->requestOptions[CURLOPT_URL] = $this->url;
        return $this->url;
    }

    /**
     * Prepare the payload of parameters to be sent with the request
     */
    protected function preparePayload() {
        if ($this->method != 'GET') {
            $format = $this->getOption('format','json');
            switch ($format) {
                case 'json':
                    if (empty($this->requestOptions[CURLOPT_HTTPHEADER])) $this->requestOptions[CURLOPT_HTTPHEADER] = array();
                    $this->requestOptions[CURLOPT_HTTPHEADER][] = 'Content-Type: application/json; charset=utf-8';
                    if (!empty($this->requestParameters)) {
                        $params = $this->requestParameters;
                        if (!empty($this->config['useRootNodeInJSON'])) {
                            $params = array($this->rootNode => $params);
                        }
                        $json = json_encode($params);
                        $this->requestOptions[CURLOPT_POSTFIELDS] = $json;
                    }
                    break;
                case 'xml':
                    if (empty($this->requestOptions[CURLOPT_HTTPHEADER])) $this->requestOptions[CURLOPT_HTTPHEADER] = array();
                    $this->requestOptions[CURLOPT_HTTPHEADER][] = 'Content-Type: application/xml; charset=utf-8';
                    if (!empty($this->requestParameters)) {
                        $xml = $this->toXml($this->requestParameters,$this->rootNode);
                        $this->requestOptions[CURLOPT_POSTFIELDS] = $xml;
                    }
                    break;
                default:
                    $this->requestOptions[CURLOPT_POSTFIELDS] = $this->_formatQuery($this->requestParameters);
                    break;
            }
        }

        if ($this->method == 'POST') {
            $this->requestOptions[CURLOPT_POST] = true;
        } elseif ($this->method != 'GET') {
            $this->requestOptions[CURLOPT_CUSTOMREQUEST] = $this->method;
        }
    }

    /**
     * Prepare the request options to be sent, setting them on the cURL handle
     */
    protected function prepareRequestOptions() {
        $curlOptions = $this->getOption('curlOptions');
        if (!empty($curlOptions) && is_array($curlOptions)) {
            foreach ($curlOptions as $key => $value) {
                $this->requestOptions[$key] = $value;
            }
        }
    }

    /**
     * Setup the default request options
     */
    private function _setDefaultRequestOptions() {
        $this->requestOptions = array(
            CURLOPT_HEADER => $this->getOption('header',true),
            CURLOPT_RETURNTRANSFER => $this->getOption('returnTransfer',true),
            CURLOPT_FOLLOWLOCATION => $this->getOption('followLocation',true),
            CURLOPT_TIMEOUT => $this->getOption('timeout',240),
            CURLOPT_USERAGENT => $this->getOption('userAgent'),
            CURLOPT_CONNECTTIMEOUT => $this->getOption('connectTimeout',0),
            CURLOPT_DNS_CACHE_TIMEOUT => $this->getOption('dnsCacheTimeout',120),
            CURLOPT_VERBOSE => $this->getOption('verbose',false),
            CURLOPT_SSL_VERIFYHOST => $this->getOption('sslVerifyhost',2),
            CURLOPT_SSL_VERIFYPEER => $this->getOption('sslVerifypeer',false),
            CURLOPT_COOKIE => $this->getOption('cookie',''),
            CURLOPT_COOKIEFILE => $this->getOption('cookieFile',''),
            CURLOPT_ENCODING => $this->getOption('encoding',''),
            CURLOPT_REFERER => $this->getOption('referer',''),
            CURLOPT_USERAGENT => $this->getOption('userAgent',''),
            CURLOPT_NETRC => $this->getOption('netrc',false),
            CURLOPT_HTTPPROXYTUNNEL => $this->getOption('httpProxyTunnel',false),
            CURLOPT_FRESH_CONNECT => $this->getOption('freshConnect',false),
            CURLOPT_FORBID_REUSE => $this->getOption('forbidReuse',false),
            CURLOPT_CRLF => $this->getOption('crlf',false),
            CURLOPT_AUTOREFERER => $this->getOption('autoreferer',false),
            CURLOPT_MAXREDIRS => $this->getOption('maxRedirs',3),
        );

        $proxy = $this->getOption('proxy',false);
        if (!empty($proxy)) {
            $this->requestOptions = array_merge($this->requestOptions,array(
                CURLOPT_PROXY => $proxy,
                CURLOPT_PROXYAUTH => $this->getOption('proxyAuth',CURLAUTH_BASIC),
                CURLOPT_PROXYPORT => $this->getOption('proxyPort',80),
                CURLOPT_PROXYTYPE => $this->getOption('proxyType',CURLPROXY_HTTP),
            ));
            $username = $this->getOption('proxyUsername');
            $password = $this->getOption('proxyPassword','');
            if (!empty($username)) {
                $this->requestOptions[CURLOPT_PROXYUSERPWD] = $username.':'.$password;
            }
        }
    }

    /**
     * Format an array of parameters into a query string
     * @param array $parameters
     * @return string
     */
    private function _formatQuery(array $parameters){
        $query = http_build_query($parameters);
        return rtrim($query);
    }

    /**
     * @param array $parameters
     * @param string $rootNode
     * @return string
     */
    public function toXml($parameters,$rootNode) {
        $doc = new DOMDocument("1.0",'UTF-8');
        $root = $doc->appendChild($doc->createElement($rootNode));
        $this->_populateXmlDoc($doc, $root, $parameters);
        return $doc->saveXML();
    }

    /**
     * @param DOMDocument $doc
     * @param DOMNode $node
     * @param array|DOMNode $parameters
     */
    protected function _populateXmlDoc(&$doc, &$node, &$parameters) {
        foreach ($parameters as $key => $val) {
            if (is_array($val)) {
                if (empty($val)) {
                    continue;
                }
                $attribute_node = $node->appendChild($doc->createElement($key));
                foreach ($val as $child => $childValue) {
                    if (is_null($child) || is_null($childValue)) {
                        continue;
                    } elseif (is_string($child) && !is_null($childValue)) {
                        // e.g. "<items><property>1000</property></items>"
                        $attribute_node->appendChild($doc->createElement($child, $childValue));
                    } elseif (is_int($child) && !is_null($childValue)) {
                        if (is_object($childValue)) {
                            // e.g. "<items><item>...</item></items>"
                            $this->_populateXmlDoc($doc, $attribute_node, $childValue);
                        } elseif (substr($key, -1) == "s") {
                            // e.g. "<items><item>gold</item><item>monthly</item></items>"
                            $attribute_node->appendChild($doc->createElement(substr($key, 0, -1), $childValue));
                        }
                    }
                }
            } elseif (is_object($val)) {
                $this->_populateXmlDoc($doc,$node,$val);
            } else {
                $node->appendChild($doc->createElement($key, $val));
            }
        }
    }
}

/**
 * Response class for REST requests
 *
 * @package modx
 * @subpackage rest
 */
class RestClientResponse {
    /** @var modX $modx */
    public $modx;
    /** @var array $config */
    public $config = array();
    /** @var string $response */
    public $response;
    /** @var int $headerSize */
    public $headerSize = 0;
    /** @var string $responseBody */
    public $responseBody;
    /** @var string $responseInfo */
    public $responseInfo;
    /** @var string $responseError */
    public $responseError;
    /** @var mixed $responseHeaders */
    public $responseHeaders;

    /**
     * Constructor for RestClientResponse class.
     *
     * @param modX $modx A reference to the modX instance
     * @param string $response The response data
     * @param int $headerSize The size of the response header, in bytes
     * @param array $config An array of configuration options
     */
    function __construct(modX &$modx,$response = '',$headerSize = 0,array $config = array()) {
        $this->modx =& $modx;
        $this->config = array_merge($this->config,$config);
        $this->response = $response;
        $this->headerSize = $headerSize;
        $this->setResponseBody($response);
    }

    /**
     * Set and parse the response body
     * @param string $result
     */
    public function setResponseBody($result) {
        $this->responseBody = $this->_parse($result);
    }

    /**
     * Set the response info
     * @param string $info
     */
    public function setResponseInfo($info) {
        $this->responseInfo = $info;
    }

    /**
     * Set the response error, if any
     * @param string $error
     */
    public function setResponseError($error) {
        $this->responseError = $error;
    }

    /**
     * Return the processed result based on the format the response was returned in
     * @return array
     */
    public function process() {
        switch ($this->config['format']) {
            case 'xml':
                $result = $this->fromXML($this->responseBody);
                break;
            case 'json':
            default:
                $result = $this->modx->fromJSON($this->responseBody);
                break;
        }
        return !empty($result) ? $result : array();
    }

    /**
     * Parse the result
     * @param string $result
     * @return string
     */
    public function _parse($result) {
        $headers = array();
        $httpVer = strtok($result, "\n");

        while($line = strtok("\n")){
            if(strlen(trim($line)) == 0) break;

            list($key, $value) = explode(':', $line, 2);
            $key = trim(strtolower(str_replace('-', '_', $key)));
            $value = trim($value);
            if(empty($headers[$key])){
                $headers[$key] = $value;
            }
            elseif(is_array($headers[$key])){
                $headers[$key][] = $value;
            }
            else {
                $headers[$key] = array($headers[$key], $value);
            }
        }

        $this->responseHeaders = (object) $headers;
        return substr($result,$this->headerSize);
    }

    /**
     * Convert JSON into an array
     *
     * @param string $data
     * @return array
     */
    protected function fromJSON($data) {
        return $this->modx->fromJSON($data);
    }

    /**
     * Convert XML into an array
     *
     * @param string|SimpleXMLElement $xml
     * @param mixed $attributesKey
     * @param mixed $childrenKey
     * @param mixed $valueKey
     * @return array
     */
    protected function fromXML($xml,$attributesKey=null,$childrenKey=null,$valueKey=null){
        if (is_string($xml)) {
            $xml = simplexml_load_string($xml);
        }
        if (empty($xml)) return '';
        if($childrenKey && !is_string($childrenKey)){$childrenKey = '@children';}
        if($attributesKey && !is_string($attributesKey)){$attributesKey = '@attributes';}
        if($valueKey && !is_string($valueKey)){$valueKey = '@values';}

        $return = array();
        $name = $xml->getName();
        $_value = trim((string)$xml);
        if(!strlen($_value)){$_value = null;};

        if($_value!==null){
            if($valueKey){$return[$valueKey] = $_value;}
            else{$return = $_value;}
        }

        $children = array();
        $first = true;
        foreach($xml->children() as $elementName => $child){
            $value = $this->fromXML($child,$attributesKey, $childrenKey,$valueKey);
            if(isset($children[$elementName])){
                if(is_array($children[$elementName])){
                    if($first){
                        $temp = $children[$elementName];
                        unset($children[$elementName]);
                        $children[$elementName][] = $temp;
                        $first=false;
                    }
                    $children[$elementName][] = $value;
                }else{
                    $children[$elementName] = array($children[$elementName],$value);
                }
            }
            else{
                $children[$elementName] = $value;
            }
        }
        if($children){
            if($childrenKey){$return[$childrenKey] = $children;}
            else{$return = array_merge($return,$children);}
        }

        $attributes = array();
        foreach($xml->attributes() as $name=>$value){
            $attributes[$name] = trim($value);
        }
        if($attributes){
            if($attributesKey){$return[$attributesKey] = $attributes;}
            else if(is_array($attributes) && is_array($return)) {
                $return = array_merge($return, $attributes);
            }
        }

        return $return;
    }
}
