<?php

namespace JsonRPC;

use ReflectionFunction;
use Closure;

/**
 * JsonRPC server class
 *
 * @package JsonRPC
 * @author Frederic Guillot
 * @license Unlicense http://unlicense.org/
 */
class Server
{
    /**
     * Data received from the client
     *
     * @access private
     * @var string
     */
    private $payload;

    /**
     * List of procedures
     *
     * @static
     * @access private
     * @var array
     */
    static private $procedures = array();

    /**
     * Constructor
     *
     * @access public
     * @param  string   $payload   Client data
     */
    public function __construct($payload = '')
    {
        $this->payload = $payload;
    }

    /**
     * IP based client restrictions
     *
     * Return an HTTP error 403 if the client is not allowed
     *
     * @access public
     * @param  array   $hosts   List of hosts
     */
    public function allowHosts(array $hosts) {

        if (! in_array($_SERVER['REMOTE_ADDR'], $hosts)) {

            header('Content-Type: application/json');
            header('HTTP/1.0 403 Forbidden');
            echo '["Access Forbidden"]';
            exit;
        }
    }

    /**
     * HTTP Basic authentication
     *
     * Return an HTTP error 401 if the client is not allowed
     *
     * @access public
     * @param  array   $users   Map of username/password
     */
    public function authentication(array $users)
    {
        // OVH workaround
        if (isset($_SERVER['REMOTE_USER'])) {
            list($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW']) = explode(':', base64_decode(substr($_SERVER['REMOTE_USER'], 6)));
        }

        if (! isset($_SERVER['PHP_AUTH_USER']) ||
            ! isset($users[$_SERVER['PHP_AUTH_USER']]) ||
            $users[$_SERVER['PHP_AUTH_USER']] !== $_SERVER['PHP_AUTH_PW']) {

            header('WWW-Authenticate: Basic realm="JsonRPC"');
            header('Content-Type: application/json');
            header('HTTP/1.0 401 Unauthorized');
            echo '["Authentication failed"]';
            exit;
        }
    }

    /**
     * Register a new procedure
     *
     * @access public
     * @param  string   $name       Procedure name
     * @param  closure  $callback   Callback
     */
    public function register($name, Closure $callback)
    {
        self::$procedures[$name] = $callback;
    }

    /**
     * Unregister a procedure
     *
     * @access public
     * @param  string   $name       Procedure name
     */
    public function unregister($name)
    {
        if (isset(self::$procedures[$name])) {
            unset(self::$procedures[$name]);
        }
    }

    /**
     * Unregister all procedures
     *
     * @access public
     */
    public function unregisterAll()
    {
        self::$procedures = array();
    }

    /**
     * Return the response to the client
     *
     * @access public
     * @param  array    $data      Data to send to the client
     * @param  array    $payload   Incoming data
     * @return string
     */
    public function getResponse(array $data, array $payload = array())
    {
        if (! array_key_exists('id', $payload)) {
            return '';
        }

        $response = array(
            'jsonrpc' => '2.0',
            'id' => $payload['id']
        );

        $response = array_merge($response, $data);

        @header('Content-Type: application/json');
        return json_encode($response);
    }

    /**
     * Map arguments to the procedure
     *
     * @access public
     * @param  array    $request_params      Incoming arguments
     * @param  array    $method_params       Procedure arguments
     * @param  array    $params              Arguments to pass to the callback
     * @param  integer  $nb_required_params  Number of required parameters
     * @return bool
     */
    public function mapParameters(array $request_params, array $method_params, array &$params, $nb_required_params)
    {
        if (count($request_params) < $nb_required_params) {
            return false;
        }

        // Positional parameters
        if (array_keys($request_params) === range(0, count($request_params) - 1)) {
            $params = $request_params;
            return true;
        }

        // Named parameters
        foreach ($method_params as $p) {

            $name = $p->getName();

            if (isset($request_params[$name])) {
                $params[$name] = $request_params[$name];
            }
            else if ($p->isDefaultValueAvailable()) {
                $params[$name] = $p->getDefaultValue();
            }
            else {
                return false;
            }
        }

        return true;
    }

    /**
     * Parse the payload and test if the parsed JSON is ok
     *
     * @access public
     * @return boolean
     */
    public function isValidJsonFormat()
    {
        if (empty($this->payload)) {
            $this->payload = file_get_contents('php://input');
        }

        if (is_string($this->payload)) {
            $this->payload = json_decode($this->payload, true);
        }

        return is_array($this->payload);
    }

    /**
     * Test if all required JSON-RPC parameters are here
     *
     * @access public
     * @return boolean
     */
    public function isValidJsonRpcFormat()
    {
        if (! isset($this->payload['jsonrpc']) ||
            ! isset($this->payload['method']) ||
            ! is_string($this->payload['method']) ||
            $this->payload['jsonrpc'] !== '2.0' ||
            (isset($this->payload['params']) && ! is_array($this->payload['params']))) {

            return false;
        }

        return true;
    }

    /**
     * Return true if we have a batch request
     *
     * @access public
     * @return boolean
     */
    private function isBatchRequest()
    {
        return array_keys($this->payload) === range(0, count($this->payload) - 1);
    }

    /**
     * Handle batch request
     *
     * @access private
     * @return string
     */
    private function handleBatchRequest()
    {
        $responses = array();

        foreach ($this->payload as $payload) {

            if (! is_array($payload)) {

                $responses[] = $this->getResponse(array(
                    'error' => array(
                        'code' => -32600,
                        'message' => 'Invalid Request'
                    )),
                    array('id' => null)
                );
            }
            else {

                $server = new Server($payload);
                $response = $server->execute();

                if ($response) {
                    $responses[] = $response;
                }
            }
        }

        return empty($responses) ? '' : '['.implode(',', $responses).']';
    }

    /**
     * Parse incoming requests
     *
     * @access public
     * @return string
     */
    public function execute()
    {
        // Invalid Json
        if (! $this->isValidJsonFormat()) {
            return $this->getResponse(array(
                'error' => array(
                    'code' => -32700,
                    'message' => 'Parse error'
                )),
                array('id' => null)
            );
        }

        // Handle batch request
        if ($this->isBatchRequest()){
            return $this->handleBatchRequest();
        }

        // Invalid JSON-RPC format
        if (! $this->isValidJsonRpcFormat()) {

            return $this->getResponse(array(
                'error' => array(
                    'code' => -32600,
                    'message' => 'Invalid Request'
                )),
                array('id' => null)
            );
        }

        // Procedure not found
        if (! isset(self::$procedures[$this->payload['method']])) {

            return $this->getResponse(array(
                'error' => array(
                    'code' => -32601,
                    'message' => 'Method not found'
                )),
                $this->payload
            );
        }

        // Execute the procedure
        $callback = self::$procedures[$this->payload['method']];
        $params = array();

        $reflection = new ReflectionFunction($callback);

        if (isset($this->payload['params'])) {

            $parameters = $reflection->getParameters();

            if (! $this->mapParameters($this->payload['params'], $parameters, $params, $reflection->getNumberOfRequiredParameters())) {

                return $this->getResponse(array(
                    'error' => array(
                        'code' => -32602,
                        'message' => 'Invalid params'
                    )),
                    $this->payload
                );
            }
        }

        $result = $reflection->invokeArgs($params);

        return $this->getResponse(array('result' => $result), $this->payload);
    }
}
