<?php

class Divido_ApiRequestor
{
    /**
     * @var string $apiKey The API key that's to be used to make requests.
     */
    public $apiKey;

    private static $_preFlight;

    private static function blacklistedCerts()
    {
        return array(
            '05c0b3643694470a888c6e7feb5c9e24e823dc53',
            '5b7dc7fbc98d78bf76d4d4fa6f597a0c901fad5c',
        );
    }

    public function __construct($apiKey = null, $sharedSecret = null)
    {
        $this->_apiKey = $apiKey;
        $this->_sharedSecret = $sharedSecret;
    }

    /**
     * @param string $url The path to the API endpoint.
     *
     * @returns string The full path.
     */
    public static function apiUrl($url = '')
    {
        $apiBase = Divido::$apiBase;
        return "$apiBase$url";
    }

    /**
     * @param string|mixed $value A string to UTF8-encode.
     *
     * @returns string|mixed The UTF8-encoded string, or the object passed in if
     *    it wasn't a string.
     */
    public static function utf8($value)
    {
        if (is_string($value)
            && mb_detect_encoding($value, "UTF-8", true) != "UTF-8") {
            return utf8_encode($value);
        } else {
            return $value;
        }
    }

    private static function _encodeObjects($d)
    {
        if ($d instanceof Divido_ApiResource) {
            return self::utf8($d->id);
        } else if ($d === true) {
            return 'true';
        } else if ($d === false) {
            return 'false';
        } else if (is_array($d)) {
            $res = array();
            foreach ($d as $k => $v) {
                $res[$k] = self::_encodeObjects($v);
            }

            return $res;
        } else {
            return self::utf8($d);
        }
    }

    /**
     * @param array $arr An map of param keys to values.
     * @param string|null $prefix (It doesn't look like we ever use $prefix...)
     *
     * @returns string A querystring, essentially.
     */
    public static function encode($arr, $prefix = null)
    {
        if (!is_array($arr)) {
            return $arr;
        }

        $r = array();
        foreach ($arr as $k => $v) {
            if (is_null($v)) {
                continue;
            }

            if ($prefix && $k && !is_int($k)) {
                $k = $prefix . "[" . $k . "]";
            } else if ($prefix) {
                $k = $prefix . "[]";
            }

            if (is_array($v)) {
                $r[] = self::encode($v, $k, true);
            } else {
                $r[] = urlencode($k) . "=" . urlencode($v);
            }
        }

        return implode("&", $r);
    }

    /**
     * @param string $method
     * @param string $url
     * @param array|null $params
     *
     * @return array An array whose first element is the response and second
     *    element is the API key used to make the request.
     */
    public function request($method, $url, $params = null)
    {
        if (!$params) {
            $params = array();
        }

        list($rbody, $rcode, $myApiKey) = $this->_requestRaw($method, $url, $params);

        $resp = $this->_interpretResponse($rbody, $rcode);
        return array($resp, $myApiKey);
    }

    /**
     * @param string $rbody A JSON string.
     * @param int $rcode
     * @param array $resp
     *
     * @throws Divido_InvalidRequestError if the error is caused by the user.
     * @throws Divido_AuthenticationError if the error is caused by a lack of
     *    permissions.
     * @throws Divido_PaymentError if the error is the error code is 402 (payment
     *    required)
     * @throws Divido_ApiError otherwise.
     */
    public function handleApiError($rbody, $rcode, $resp)
    {
        if (!is_array($resp) || !isset($resp['error'])) {
            $msg = "Invalid response object from API: $rbody "
                . "(HTTP response code was $rcode)";
            throw new Divido_ApiError($msg, $rcode, $rbody, $resp);
        }

        $error = $resp['error'];
        $msg = isset($error['message']) ? $error['message'] : null;
        $param = isset($error['param']) ? $error['param'] : null;
        $code = isset($error['code']) ? $error['code'] : null;

        switch ($rcode) {
            case 400:
                if ($code == 'rate_limit') {
                    throw new Divido_RateLimitError(
                        $msg, $param, $rcode, $rbody, $resp
                    );
                }
            case 404:
                throw new Divido_InvalidRequestError(
                    $msg, $param, $rcode, $rbody, $resp
                );
            case 401:
                throw new Divido_AuthenticationError($msg, $rcode, $rbody, $resp);
            case 402:
                throw new Divido_PaymentError($msg, $param, $code, $rcode, $rbody, $resp);
            default:
                throw new Divido_ApiError($msg, $rcode, $rbody, $resp);
        }
    }

    private function _requestRaw($method, $url, $params)
    {
        $myApiKey = $this->_apiKey;
        if (!$myApiKey) {
            $myApiKey = Divido::$apiKey;
        }

        $sharedSecret = $this->_sharedSecret;
        if (!$sharedSecret) {
            $sharedSecret = Divido::$sharedSecret;
        }

        if (!$myApiKey) {
            $msg = 'No API key provided.  (HINT: set your API key using '
                . '"Divido::setApiKey(<API-KEY>)".  You can generate API keys from '
                . 'the Divido web interface.  See http://docs.divido.com/api for '
                . 'details, or email support@divido.com if you have any questions.';
            throw new Divido_AuthenticationError($msg);
        }

        $absUrl = $this->apiUrl($url);
        $params = self::_encodeObjects($params);
        $langVersion = phpversion();
        $uname = php_uname();
        $ua = array(
            'bindings_version' => Divido::VERSION,
            'lang' => 'php',
            'lang_version' => $langVersion,
            'publisher' => 'divido',
            'uname' => $uname,
        );

        $headers = array(
            'X-Divido-Client-User-Agent: ' . json_encode($ua),
            'User-Agent: Divido/v1 PhpBindings/' . Divido::VERSION,
            'Authorization: Bearer ' . $myApiKey,
            'Content-Type: application/x-www-form-urlencoded',
        );

        if (!empty($sharedSecret)) {
            $queryString = http_build_query($params);
            $string = (strlen($queryString)) ? $url . "?" . $queryString : $url;
            $hmac = base64_encode(hash_hmac('sha256', $string, $sharedSecret, true));

            $headers[] = 'X-DIVIDO-HMAC-SHA256: ' . $hmac;
        }

        if (Divido::$apiVersion) {
            $headers[] = 'Divido-Version: ' . Divido::$apiVersion;
        }
        list($rbody, $rcode) = $this->_curlRequest(
            $method,
            $absUrl,
            $headers,
            $params
        );
        return array($rbody, $rcode, $myApiKey);
    }

    private function _interpretResponse($rbody, $rcode)
    {
        try {
            $resp = json_decode($rbody, true);
        } catch (Exception $e) {
            $msg = "Invalid response body from API: $rbody "
                . "(HTTP response code was $rcode)";
            throw new Divido_ApiError($msg, $rcode, $rbody);
        }

        if ($rcode < 200 || $rcode >= 300) {
            $this->handleApiError($rbody, $rcode, $resp);
        }
        return $resp;
    }

    private function _curlRequest($method, $absUrl, $headers, $params)
    {

        if (!self::$_preFlight) {
            self::$_preFlight = $this->checkSslCert($this->apiUrl());
        }

        /*
        $myApiKey = $this->_apiKey;
        if (!$myApiKey)
        $myApiKey = Divido::$apiKey;

        if (!isset($params['merchant'])) {
        $params['merchant'] = $myApiKey;
        }
         */

        $curl = curl_init();
        $method = strtolower($method);
        $opts = array();

        if ($method == 'get') {
            $opts[CURLOPT_HTTPGET] = 1;
            if (count($params) > 0) {
                $encoded = self::encode($params);
                $absUrl = "$absUrl?$encoded";
            }
        } else if ($method == 'post') {
            $opts[CURLOPT_POST] = 1;
            //$opts[CURLOPT_POSTFIELDS] = self::encode($params);
            $opts[CURLOPT_POSTFIELDS] = http_build_query($params);
        } else if ($method == 'delete') {
            $opts[CURLOPT_CUSTOMREQUEST] = 'DELETE';
            if (count($params) > 0) {
                $encoded = self::encode($params);
                $absUrl = "$absUrl?$encoded";
            }
        } else {
            throw new Divido_ApiError("Unrecognized method $method");
        }

        $absUrl = self::utf8($absUrl);
        // print "absUrl: ".$absUrl."<br />";
        $opts[CURLOPT_URL] = $absUrl;

        /*
        if (isset($myApiKey)) {
        // $opts[CURLOPT_USERPWD] = $params['merchant'].":";
        curl_setopt($curl,CURLOPT_USERPWD,$myApiKey.":");
        }
         */
        $opts[CURLOPT_RETURNTRANSFER] = true;
        $opts[CURLOPT_CONNECTTIMEOUT] = 30;
        $opts[CURLOPT_TIMEOUT] = 80;
        $opts[CURLOPT_RETURNTRANSFER] = true;
        $opts[CURLOPT_HTTPHEADER] = $headers;
        if (!Divido::$verifySslCerts) {
            $opts[CURLOPT_SSL_VERIFYPEER] = false;
        }

        curl_setopt_array($curl, $opts);
        $rbody = curl_exec($curl);

        // print_r($params);exit;
        // print "url: ".$absUrl;Exit;
        // print "rbody: " . $rbody;exit;

        if (!defined('CURLE_SSL_CACERT_BADFILE')) {
            define('CURLE_SSL_CACERT_BADFILE', 77); // constant not defined in PHP
        }

        $errno = curl_errno($curl);
        if ($errno == CURLE_SSL_CACERT ||
            $errno == CURLE_SSL_PEER_CERTIFICATE ||
            $errno == CURLE_SSL_CACERT_BADFILE) {
            array_push(
                $headers,
                'X-Divido-Client-Info: {"ca":"using Divido-supplied CA bundle"}'
            );
            $cert = $this->caBundle();
            curl_setopt($curl, CURLOPT_HTTPHEADER, $headers);
            curl_setopt($curl, CURLOPT_CAINFO, $cert);
            $rbody = curl_exec($curl);
        }

        if ($rbody === false) {
            $errno = curl_errno($curl);
            $message = curl_error($curl);
            curl_close($curl);
            $this->handleCurlError($errno, $message);
        }

        $rcode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
        curl_close($curl);
        return array($rbody, $rcode);
    }

    /**
     * @param number $errno
     * @param string $message
     * @throws Divido_ApiConnectionError
     */
    public function handleCurlError($errno, $message)
    {
        $apiBase = Divido::$apiBase;
        switch ($errno) {
            case CURLE_COULDNT_CONNECT:
            case CURLE_COULDNT_RESOLVE_HOST:
            case CURLE_OPERATION_TIMEOUTED:
                $msg = "Could not connect to Divido ($apiBase).  Please check your "
                    . "internet connection and try again.  If this problem persists, "
                    . "you should check Divido's service status at "
                    . "https://twitter.com/dividostatus, or";
                break;
            case CURLE_SSL_CACERT:
            case CURLE_SSL_PEER_CERTIFICATE:
                $msg = "Could not verify Divido's SSL certificate.  Please make sure "
                    . "that your network is not intercepting certificates.  "
                    . "(Try going to $apiBase in your browser.)  "
                    . "If this problem persists,";
                break;
            default:
                $msg = "Unexpected error communicating with Divido.  "
                    . "If this problem persists,";
        }
        $msg .= " let us know at support@divido.com.";

        $msg .= "\n\n(Network error [errno $errno]: $message)";
        throw new Divido_ApiConnectionError($msg);
    }

    /**
     * Preflight the SSL certificate presented by the backend. This isn't 100%
     * bulletproof, in that we're not actually validating the transport used to
     * communicate with Divido, merely that the first attempt to does not use a
     * revoked certificate.
     *
     * Unfortunately the interface to OpenSSL doesn't make it easy to check the
     * certificate before sending potentially sensitive data on the wire. This
     * approach raises the bar for an attacker significantly.
     */
    private function checkSslCert($url)
    {

        return true;

        if (version_compare(PHP_VERSION, '5.3.0', '<')) {
            error_log(
                'Warning: This version of PHP is too old to check SSL certificates ' .
                'correctly. Divido cannot guarantee that the server has a ' .
                'certificate which is not blacklisted'
            );
            return true;
        }

        if (strpos(PHP_VERSION, 'hiphop') !== false) {
            error_log(
                'Warning: HHVM does not support Divido\'s SSL certificate ' .
                'verification. (See http://docs.hhvm.com/manual/en/context.ssl.php) ' .
                'Divido cannot guarantee that the server has a certificate which is ' .
                'not blacklisted'
            );
            return true;
        }

        $url = parse_url($url);
        $port = isset($url["port"]) ? $url["port"] : 443;
        $url = "ssl://{$url["host"]}:{$port}";

        $sslContext = stream_context_create(
            array('ssl' => array(
                'capture_peer_cert' => true,
                'verify_peer' => true,
                'cafile' => $this->caBundle(),
            ))
        );
        $result = stream_socket_client(
            $url, $errno, $errstr, 30, STREAM_CLIENT_CONNECT, $sslContext
        );
        if ($errno !== 0) {
            $apiBase = Divido::$apiBase;
            throw new Divido_ApiConnectionError(
                'Could not connect to Divido (' . $apiBase . ').  Please check your ' .
                'internet connection and try again.  If this problem persists, ' .
                'you should check Divido\'s service status at ' .
                'https://twitter.com/dividostatus. Reason was: ' . $errstr
            );
        }

        $params = stream_context_get_params($result);

        $cert = $params['options']['ssl']['peer_certificate'];

        openssl_x509_export($cert, $pemCert);

        if (self::isBlackListed($pemCert)) {
            throw new Divido_ApiConnectionError(
                'Invalid server certificate. You tried to connect to a server that ' .
                'has a revoked SSL certificate, which means we cannot securely send ' .
                'data to that server.  Please email support@divido.com if you need ' .
                'help connecting to the correct API server.'
            );
        }

        return true;
    }

    /* Checks if a valid PEM encoded certificate is blacklisted
     * @return boolean
     */
    public static function isBlackListed($certificate)
    {
        $certificate = trim($certificate);
        $lines = explode("\n", $certificate);

        // Kludgily remove the PEM padding
        array_shift($lines);
        array_pop($lines);

        $derCert = base64_decode(implode("", $lines));
        $fingerprint = sha1($derCert);
        return in_array($fingerprint, self::blacklistedCerts());
    }

    private function caBundle()
    {
        return dirname(__FILE__) . '/../data/ca-certificates.crt';
    }
}
