<?php

namespace PhpConsole;

/**
 * PHP Console client connector that encapsulates client-server protocol implementation
 *
 * You will need to install Google Chrome extension "PHP Console"
 * https://chrome.google.com/webstore/detail/php-console/nfhmhhlpfleoednkpnnnkolmclajemef
 *
 * @package PhpConsole
 * @version 3.1
 * @link http://consle.com
 * @author Sergey Barbushin http://linkedin.com/in/barbushin
 * @copyright © Sergey Barbushin, 2011-2013. All rights reserved.
 * @license http://www.opensource.org/licenses/BSD-3-Clause "The BSD 3-Clause License"
 * @codeCoverageIgnore
 */
class Connector {

	const SERVER_PROTOCOL = 5;
	const SERVER_COOKIE = 'php-console-server';
	const CLIENT_INFO_COOKIE = 'php-console-client';
	const CLIENT_ENCODING = 'UTF-8';
	const HEADER_NAME = 'PHP-Console';
	const POSTPONE_HEADER_NAME = 'PHP-Console-Postpone';
	const POST_VAR_NAME = '__PHP_Console';
	const POSTPONE_REQUESTS_LIMIT = 10;
	const PHP_HEADERS_SIZE = 1000; // maximum PHP response headers size
	const CLIENT_HEADERS_LIMIT = 200000;

	/** @var Connector */
	protected static $instance;
	/** @var  Storage|null */
	private static $postponeStorage;

	/** @var  Dumper|null */
	protected $dumper;
	/** @var  Dispatcher\Debug|null */
	protected $debugDispatcher;
	/** @var  Dispatcher\Errors|null */
	protected $errorsDispatcher;
	/** @var  Dispatcher\Evaluate|null */
	protected $evalDispatcher;
	/** @var  string */
	protected $serverEncoding = self::CLIENT_ENCODING;
	protected $sourcesBasePath;
	protected $headersLimit;

	/** @var Client|null */
	private $client;
	/** @var Auth|null */
	private $auth;
	/** @var Message[] */
	private $messages = array();
	private $postponeResponseId;
	private $isSslOnlyMode = false;
	private $isActiveClient = false;
	private $isAuthorized = false;
	private $isEvalListenerStarted = false;
	private $registeredShutDowns = 0;

	/**
	 * @return static
	 */
	public static function getInstance() {
		if(!self::$instance) {
			self::$instance = new static();
		}
		return self::$instance;
	}

	/**
	 * Set storage for postponed response data. Storage\Session is used by default, but if you have problems with overridden session handler you should use another one.
	 * IMPORTANT: This method cannot be called after Connector::getInstance()
	 * @param Storage $storage
	 * @throws \Exception
	 */
	public static function setPostponeStorage(Storage $storage) {
		if(self::$instance) {
			throw new \Exception(__METHOD__ . ' can be called only before ' . __CLASS__ . '::getInstance()');
		}
		self::$postponeStorage = $storage;
	}

	/**
	 * @return Storage
	 */
	private function getPostponeStorage() {
		if(!self::$postponeStorage) {
			self::$postponeStorage = new Storage\Session();
		}
		return self::$postponeStorage;
	}

	protected function __construct() {
		$this->initConnection();
		$this->setServerEncoding(ini_get('mbstring.internal_encoding') ? : self::CLIENT_ENCODING);
	}

	private final function __clone() {
	}

	/**
	 * Detect script is running in command-line mode
	 * @return int
	 */
	protected function isCliMode() {
		return PHP_SAPI == 'cli';
	}

	/**
	 * Notify clients that there is active PHP Console on server & check if there is request from client with active PHP Console
	 * @throws \Exception
	 */
	private function initConnection() {
		if($this->isCliMode()) {
			return;
		}

		$this->initServerCookie();
		$this->client = $this->initClient();

		if($this->client) {
			ob_start();
			$this->isActiveClient = true;
			$this->registerFlushOnShutDown();
			$this->setHeadersLimit(isset($_SERVER['SERVER_SOFTWARE']) && stripos($_SERVER['SERVER_SOFTWARE'], 'nginx') !== false
				? 4096 // default headers limit for Nginx
				: 8192 // default headers limit for all other web-servers
			);
			$this->listenGetPostponedResponse();
			$this->postponeResponseId = $this->setPostponeHeader();
		}
	}

	/**
	 * Get connected client data(
	 * @return Client|null
	 * @throws \Exception
	 */
	private function initClient() {
		if(isset($_COOKIE[self::CLIENT_INFO_COOKIE])) {
			$clientData = @json_decode(base64_decode($_COOKIE[self::CLIENT_INFO_COOKIE], true), true);
			if(!$clientData) {
				throw new \Exception('Wrong format of response cookie data: ' . $_COOKIE[self::CLIENT_INFO_COOKIE]);
			}

			$client = new Client($clientData);
			if(isset($clientData['auth'])) {
				$client->auth = new ClientAuth($clientData['auth']);
			}
			return $client;
		}
	}

	/**
	 * Notify clients that there is active PHP Console on server
	 * @throws \Exception
	 */
	private function initServerCookie() {
		if(!isset($_COOKIE[self::SERVER_COOKIE]) || $_COOKIE[self::SERVER_COOKIE] != self::SERVER_PROTOCOL) {
			$isSuccess = setcookie(self::SERVER_COOKIE, self::SERVER_PROTOCOL, null, '/');
			if(!$isSuccess) {
				throw new \Exception('Unable to set PHP Console server cookie');
			}
		}
	}

	/**
	 * Check if there is client is installed PHP Console extension
	 * @return bool
	 */
	public function isActiveClient() {
		return $this->isActiveClient;
	}

	/**
	 * Set client connection as not active
	 */
	public function disable() {
		$this->isActiveClient = false;
	}

	/**
	 * Check if client with valid auth credentials is connected
	 * @return bool
	 */
	public function isAuthorized() {
		return $this->isAuthorized;
	}

	/**
	 * Set IP masks of clients that will be allowed to connect to PHP Console
	 * @param array $ipMasks Use *(star character) for "any numbers" placeholder array('192.168.*.*', '10.2.12*.*', '127.0.0.1', '2001:0:5ef5:79fb:*:*:*:*')
	 */
	public function setAllowedIpMasks(array $ipMasks) {
		if($this->isActiveClient()) {
			if(isset($_SERVER['REMOTE_ADDR'])) {
				$ip = $_SERVER['REMOTE_ADDR'];
				foreach($ipMasks as $ipMask) {
					if(preg_match('~^' . str_replace(array('.', '*'), array('\.', '\w+'), $ipMask) . '$~i', $ip)) {
						return;
					}
				}
			}
			$this->disable();
		}
	}

	/**
	 * @return Dumper
	 */
	public function getDumper() {
		if(!$this->dumper) {
			$this->dumper = new Dumper();
		}
		return $this->dumper;
	}

	/**
	 * Override default errors dispatcher
	 * @param Dispatcher\Errors $dispatcher
	 */
	public function setErrorsDispatcher(Dispatcher\Errors $dispatcher) {
		$this->errorsDispatcher = $dispatcher;
	}

	/**
	 * Get dispatcher responsible for sending errors/exceptions messages
	 * @return Dispatcher\Errors
	 */
	public function getErrorsDispatcher() {
		if(!$this->errorsDispatcher) {
			$this->errorsDispatcher = new Dispatcher\Errors($this, $this->getDumper());
		}
		return $this->errorsDispatcher;
	}

	/**
	 * Override default debug dispatcher
	 * @param Dispatcher\Debug $dispatcher
	 */
	public function setDebugDispatcher(Dispatcher\Debug $dispatcher) {
		$this->debugDispatcher = $dispatcher;
	}

	/**
	 * Get dispatcher responsible for sending debug messages
	 * @return Dispatcher\Debug
	 */
	public function getDebugDispatcher() {
		if(!$this->debugDispatcher) {
			$this->debugDispatcher = new Dispatcher\Debug($this, $this->getDumper());
		}
		return $this->debugDispatcher;
	}

	/**
	 * Override default eval requests dispatcher
	 * @param Dispatcher\Evaluate $dispatcher
	 */
	public function setEvalDispatcher(Dispatcher\Evaluate $dispatcher) {
		$this->evalDispatcher = $dispatcher;
	}

	/**
	 * Get dispatcher responsible for handling eval requests
	 * @return Dispatcher\Evaluate
	 */
	public function getEvalDispatcher() {
		if(!$this->evalDispatcher) {
			$this->evalDispatcher = new Dispatcher\Evaluate($this, new EvalProvider(), $this->getDumper());
		}
		return $this->evalDispatcher;
	}

	/**
	 * Enable eval request to be handled by eval dispatcher. Must be called after all Connector configurations.
	 * Connector::getInstance()->setPassword() is required to be called before this method
	 * Use Connector::getInstance()->setAllowedIpMasks() for additional access protection
	 * Check Connector::getInstance()->getEvalDispatcher()->getEvalProvider() to customize eval accessibility & security options
	 * @param bool $exitOnEval
	 * @param bool $flushDebugMessages Clear debug messages handled before this method is called
	 * @throws \Exception
	 */
	public function startEvalRequestsListener($exitOnEval = true, $flushDebugMessages = true) {
		if(!$this->auth) {
			throw new \Exception('Eval dispatcher is allowed only in password protected mode. See PhpConsole\Connector::getInstance()->setPassword(...)');
		}
		if($this->isEvalListenerStarted) {
			throw new \Exception('Eval requests listener already started');
		}
		$this->isEvalListenerStarted = true;

		if($this->isActiveClient() && $this->isAuthorized() && isset($_POST[Connector::POST_VAR_NAME]['eval'])) {
			$request = $_POST[Connector::POST_VAR_NAME]['eval'];
			if(!isset($request['data']) || !isset($request['signature'])) {
				throw new \Exception('Wrong PHP Console eval request');
			}
			if($this->auth->getSignature($request['data']) !== $request['signature']) {
				throw new \Exception('Wrong PHP Console eval request signature');
			}
			if($flushDebugMessages) {
				foreach($this->messages as $i => $message) {
					if($message instanceof DebugMessage) {
						unset($this->messages[$i]);
					}
				}
			}
			$this->convertEncoding($request['data'], $this->serverEncoding, self::CLIENT_ENCODING);
			$this->getEvalDispatcher()->dispatchCode($request['data']);
			if($exitOnEval) {
				exit;
			}
		}
	}

	/**
	 * Set bath to base dir of project source code(so it will be stripped in paths displaying on client)
	 * @param $sourcesBasePath
	 * @throws \Exception
	 */
	public function setSourcesBasePath($sourcesBasePath) {
		$sourcesBasePath = realpath($sourcesBasePath);
		if(!$sourcesBasePath) {
			throw new \Exception('Path "' . $sourcesBasePath . '" not found');
		}
		$this->sourcesBasePath = $sourcesBasePath;
	}

	/**
	 * Protect PHP Console connection by password
	 *
	 * Use Connector::getInstance()->setAllowedIpMasks() for additional secure
	 * @param string $password
	 * @param bool $publicKeyByIp Set authorization token depending on client IP
	 * @throws \Exception
	 */
	public function setPassword($password, $publicKeyByIp = true) {
		if($this->auth) {
			throw new \Exception('Password already defined');
		}
		$this->convertEncoding($password, self::CLIENT_ENCODING, $this->serverEncoding);
		$this->auth = new Auth($password, $publicKeyByIp);
		if($this->client) {
			$this->isAuthorized = $this->client->auth && $this->auth->isValidAuth($this->client->auth);
		}
	}

	/**
	 * Encode var to JSON with errors & encoding handling
	 * @param $var
	 * @return string
	 * @throws \Exception
	 */
	protected function jsonEncode($var) {
		return json_encode($var, defined('JSON_UNESCAPED_UNICODE') ? JSON_UNESCAPED_UNICODE : null);
	}

	/**
	 * Recursive var data encoding conversion
	 * @param $data
	 * @param $fromEncoding
	 * @param $toEncoding
	 */
	protected function convertArrayEncoding(&$data, $toEncoding, $fromEncoding) {
		array_walk_recursive($data, array($this, 'convertWalkRecursiveItemEncoding'), array($toEncoding, $fromEncoding));
	}

	/**
	 * Encoding conversion callback for array_walk_recursive()
	 * @param string $string
	 * @param null $key
	 * @param array $args
	 */
	protected function convertWalkRecursiveItemEncoding(&$string, $key = null, array $args) {
		$this->convertEncoding($string, $args[0], $args[1]);
	}

	/**
	 * Convert string encoding
	 * @param string $string
	 * @param string $toEncoding
	 * @param string|null $fromEncoding
	 * @throws \Exception
	 */
	protected function convertEncoding(&$string, $toEncoding, $fromEncoding) {
		if($string && is_string($string) && $toEncoding != $fromEncoding) {
			static $isMbString;
			if($isMbString === null) {
				$isMbString = extension_loaded('mbstring');
			}
			if($isMbString) {
				$string = @mb_convert_encoding($string, $toEncoding, $fromEncoding) ? : $string;
			}
			else {
				$string = @iconv($fromEncoding, $toEncoding . '//IGNORE', $string) ? : $string;
			}
			if(!$string && $toEncoding == 'UTF-8') {
				$string = utf8_encode($string);
			}
		}
	}

	/**
	 * Set headers size limit for your web-server. You can auto-detect headers size limit by /examples/utils/detect_headers_limit.php
	 * @param $bytes
	 * @throws \Exception
	 */
	public function setHeadersLimit($bytes) {
		if($bytes < static::PHP_HEADERS_SIZE) {
			throw new \Exception('Headers limit cannot be less then ' . __CLASS__ . '::PHP_HEADERS_SIZE');
		}
		$bytes -= static::PHP_HEADERS_SIZE;
		$this->headersLimit = $bytes < static::CLIENT_HEADERS_LIMIT ? $bytes : static::CLIENT_HEADERS_LIMIT;
	}

	/**
	 * Set your server PHP internal encoding, if it's different from "mbstring.internal_encoding" or UTF-8
	 * @param $encoding
	 */
	public function setServerEncoding($encoding) {
		if($encoding == 'utf8' || $encoding == 'utf-8') {
			$encoding = 'UTF-8'; // otherwise mb_convert_encoding() sometime fails with error(thanks to @alexborisov)
		}
		$this->serverEncoding = $encoding;
	}

	/**
	 * Send data message to PHP Console client(if it's connected)
	 * @param Message $message
	 */
	public function sendMessage(Message $message) {
		if($this->isActiveClient()) {
			$this->messages[] = $message;
		}
	}

	/**
	 * Register shut down callback handler. Must be called after all errors handlers register_shutdown_function()
	 */
	public function registerFlushOnShutDown() {
		$this->registeredShutDowns++;
		register_shutdown_function(array($this, 'onShutDown'));
	}

	/**
	 * This method must be called only by register_shutdown_function(). Never call it manually!
	 */
	public function onShutDown() {
		$this->registeredShutDowns--;
		if(!$this->registeredShutDowns) {
			$this->proceedResponsePackage();
		}
	}

	/**
	 * Force connection by SSL for clients with PHP Console installed
	 */
	public function enableSslOnlyMode() {
		$this->isSslOnlyMode = true;
	}

	/**
	 * Check if client is connected by SSL
	 * @return bool
	 */
	protected function isSsl() {
		return (isset($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) == 'on') || (isset($_SERVER['SERVER_PORT']) && ($_SERVER['SERVER_PORT']) == 443);
	}

	/**
	 * Send response data to client
	 * @throws \Exception
	 */
	private function proceedResponsePackage() {
		if($this->isActiveClient()) {
			$response = new Response();
			$response->isSslOnlyMode = $this->isSslOnlyMode;

			if(isset($_POST[self::POST_VAR_NAME]['getBackData'])) {
				$response->getBackData = $_POST[self::POST_VAR_NAME]['getBackData'];
			}

			if(!$this->isSslOnlyMode || $this->isSsl()) {
				if($this->auth) {
					$response->auth = $this->auth->getServerAuthStatus($this->client->auth);
				}
				if(!$this->auth || $this->isAuthorized()) {
					$response->isLocal = isset($_SERVER['REMOTE_ADDR']) && ($_SERVER['REMOTE_ADDR'] == '127.0.0.1' || $_SERVER['REMOTE_ADDR'] == '::1');
					$response->docRoot = isset($_SERVER['DOCUMENT_ROOT']) ? $_SERVER['DOCUMENT_ROOT'] : null;
					$response->sourcesBasePath = $this->sourcesBasePath;
					$response->isEvalEnabled = $this->isEvalListenerStarted;
					$response->messages = $this->messages;
				}
			}

			$responseData = $this->serializeResponse($response);

			if(strlen($responseData) > $this->headersLimit || !$this->setHeaderData($responseData, self::HEADER_NAME, false)) {
				$this->getPostponeStorage()->push($this->postponeResponseId, $responseData);
			}
		}
	}

	private function setPostponeHeader() {
		$postponeResponseId = mt_rand() . mt_rand() . mt_rand();
		$this->setHeaderData($this->serializeResponse(
			new PostponedResponse(array(
				'id' => $postponeResponseId
			))
		), self::POSTPONE_HEADER_NAME, true);
		return $postponeResponseId;
	}

	private function setHeaderData($responseData, $headerName, $throwException = true) {
		if(headers_sent($file, $line)) {
			if($throwException) {
				throw new \Exception('Unable to process response data, headers already sent in ' . $file . ':' . $line . '. Try to use ob_start() and don\'t use flush().');
			}
			return false;
		}
		header($headerName . ': ' . $responseData);
		return true;
	}

	protected function objectToArray(&$var) {
		if(is_object($var)) {
			$var = get_object_vars($var);
			array_walk_recursive($var, array($this, 'objectToArray'));
		}
	}

	protected function serializeResponse(DataObject $response) {
		if($this->serverEncoding != self::CLIENT_ENCODING) {
			$this->objectToArray($response);
			$this->convertArrayEncoding($response, self::CLIENT_ENCODING, $this->serverEncoding);
		}
		return $this->jsonEncode($response);
	}

	/**
	 * Check if there is postponed response request and dispatch it
	 */
	private function listenGetPostponedResponse() {
		if(isset($_POST[self::POST_VAR_NAME]['getPostponedResponse'])) {
			header('Content-Type: application/json; charset=' . self::CLIENT_ENCODING);
			echo $this->getPostponeStorage()->pop($_POST[self::POST_VAR_NAME]['getPostponedResponse']);
			$this->disable();
			exit;
		}
	}
}

abstract class DataObject {

	public function __construct(array $properties = array()) {
		foreach($properties as $property => $value) {
			$this->$property = $value;
		}
	}
}

final class Client extends DataObject {

	public $protocol;
	/** @var ClientAuth|null */
	public $auth;
}

final class ClientAuth extends DataObject {

	public $publicKey;
	public $token;
}

final class ServerAuthStatus extends DataObject {

	public $publicKey;
	public $isSuccess;
}

final class Response extends DataObject {

	public $protocol = Connector::SERVER_PROTOCOL;
	/** @var  ServerAuthStatus */
	public $auth;
	public $docRoot;
	public $sourcesBasePath;
	public $getBackData;
	public $isLocal;
	public $isSslOnlyMode;
	public $isEvalEnabled;
	public $messages = array();
}

final class PostponedResponse extends DataObject {

	public $protocol = Connector::SERVER_PROTOCOL;
	public $isPostponed = true;
	public $id;
}

abstract class Message extends DataObject {

	public $type;
}

abstract class EventMessage extends Message {

	public $data;
	public $file;
	public $line;
	/** @var  null|TraceCall[] */
	public $trace;
}

final class TraceCall extends DataObject {

	public $file;
	public $line;
	public $call;
}

final class DebugMessage extends EventMessage {

	public $type = 'debug';
	public $tags;
}

final class ErrorMessage extends EventMessage {

	public $type = 'error';
	public $code;
	public $class;
}

final class EvalResultMessage extends Message {

	public $type = 'eval_result';
	public $return;
	public $output;
	public $time;
}
