<?php

namespace App\Classes\Sock;

use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;

/**
 * Class SocketClient
 *
 * @package App\Classes\Sock
 * @property int cps
 * @property int speed
 */
final class SocketClient {
	// For deep debugging
	private bool $DEBUG = FALSE;

	private \Socket $connection;
	private string $address_local = '';
	private int $port_local = 0;
	private string $address_remote = '';
	private int $port_remote = 0;

	// Our session state
	private array $session = [];

	private const OK				= 0;
	private const EOF				= -1;
	private const TIMEOUT			= -2;
	private const RCDO				= -3;
	private const GCOUNT			= -4;
	private const ERROR				= -5;

	private const TTY_SUCCESS		= self::OK;
	private const TTY_TIMEOUT		= self::TIMEOUT;
	private const TTY_HANGUP		= self::RCDO;
	private const TTY_ERROR			= self::ERROR;

	public const TCP_SPEED			= 115200;

	// Buffer for sending
	private const TX_BUF_SIZE		= (0x8100);
	private int $tx_ptr = 0;
	private int $tx_free = self::TX_BUF_SIZE;
	private int $tty_status = 0;
	private string $tx_buf = '';

	// Buffer for receiving
	private const RX_BUF_SIZE		= (0x8100);
	private int $rx_ptr = 0;
	private int $rx_left = 0;
	private string $rx_buf = '';

	public function __construct (\Socket $connection,int $speed=self::TCP_SPEED) {
		socket_getsockname($connection,$this->address_local,$this->port_local);
		socket_getpeername($connection,$this->address_remote,$this->port_remote);
		Log::info(sprintf('%s: + Connection from [%s] on port [%d]',__METHOD__,$this->address_remote,$this->port_remote));

		$this->connection = $connection;
	}

	public function __get($key) {
		switch ($key) {
			case 'address_remote':
			case 'port_remote':
				return $this->{$key};

			case 'cps':
			case 'speed':
				return Arr::get($this->session,$key);

			default:
				throw new \Exception(sprintf('%s: Unknown key [%s]:',__METHOD__,$key));
		}
	}

	public function __set($key,$value) {
		switch ($key) {
			case 'cps':
			case 'speed':
				return $this->session[$key] = $value;

			default:
				throw new \Exception(sprintf('%s: Unknown key [%s]:',__METHOD__,$key));
		}
	}

	/**
	 * We'll add to our transmit buffer and if doesnt have space, we'll empty it first
	 *
	 * @param string $data
	 * @return void
	 * @throws \Exception
	 */
	public function buffer_add(string $data): void
	{
		if ($this->DEBUG)
			Log::debug(sprintf('%s: + Start [%s] (%d)',__METHOD__,$data,strlen($data)));

		//$rc = self::OK;
		//$tx_ptr = self::TX_BUF_SIZE-$this->tx_free;
		$ptr = 0;
		$num_bytes = strlen($data);
		$this->tty_status = self::TTY_SUCCESS;

		while ($num_bytes) {
			if ($this->DEBUG)
				Log::debug(sprintf('%s:   - Num Bytes [%d]: TX Free [%d]',__METHOD__,$num_bytes,$this->tx_free));

			if ($num_bytes > $this->tx_free) {
				do {
					$this->buffer_flush(5);

					if ($this->tty_status == self::TTY_SUCCESS) {
						$n = min($this->tx_free,$num_bytes);
						$this->tx_buf = substr($data,$ptr,$n);
						$this->tx_free -= $n;
						$num_bytes -= $n;
						$ptr += $n;
					}

				} while ($this->tty_status != self::TTY_SUCCESS);

			} else {
				if ($this->DEBUG)
					Log::debug(sprintf('%s:   - Remaining data to send [%d]',__METHOD__,$num_bytes));

				$this->tx_buf .= substr($data,$ptr,$num_bytes);
				$this->tx_free -= $num_bytes;
				$num_bytes = 0;
			}
		}

		if ($this->DEBUG)
			Log::debug(sprintf('%s: = End [%s]',__METHOD__,strlen($this->tx_buf)));
	}

	/**
	 * Clear our TX buffer
	 */
	public function buffer_clear(): void
	{
		$this->tx_buf = '';
		$this->tx_free = self::TX_BUF_SIZE;
	}

	/**
	 * Empty our TX buffer
	 *
	 * @param int $timeout
	 * @return int
	 * @throws \Exception
	 */
	public function buffer_flush(int $timeout): int
	{
		if ($this->DEBUG)
			Log::debug(sprintf('%s: + Start [%d]',__METHOD__,$timeout));

		$rc = self::OK;
		$tx_ptr = 0;
		$restsize = self::TX_BUF_SIZE-$this->tx_free;

		$tm = $this->timer_set($timeout);
		while (self::TX_BUF_SIZE != $this->tx_free) {
			$tv = $this->timer_rest($tm);

			if ($rc = $this->canSend($tv)>0) {
				if ($this->DEBUG)
					Log::debug(sprintf('%s:  - Sending [%d]',__METHOD__,$restsize));
				$rc = $this->send(substr($this->tx_buf,$tx_ptr,$restsize),0);
				Log::debug(sprintf('%s:  - Sent [%d] (%s)',__METHOD__,$rc,Str::limit($this->tx_buf,15)));

				if ($rc == $restsize) {
					$this->tx_buf = '';
					$tx_ptr = 0;
					$this->tx_free += $rc;
					$this->buffer_clear();

				} else if ($rc > 0) {
					$tx_ptr += $rc;
					$restsize -= $rc;
				}

			} else {
				return $rc;
			}

			// @todo Enable a delay for slow clients
			//sleep(1);
			if ($this->timer_expired($tm))
				return self::ERROR;
		}

		if ($this->DEBUG)
			Log::debug(sprintf('%s: = End [%d]',__METHOD__,$rc));

		return $rc;
	}

	/**
	 * @param int $timeout
	 * @return int
	 * @throws \Exception
	 */
	public function canSend(int $timeout): int
	{
		$write = [$this->connection];

		return $this->socketSelect(NULL,$write,NULL,$timeout);
	}

	/**
	 * Close the connection with the client
	 */
	public function close(): void
	{
		socket_shutdown($this->connection);
		socket_close($this->connection);
	}

	/**
	 * Create a client socket
	 * @param string $address
	 * @param int $port
	 * @param int $speed
	 * @return static
	 * @throws SocketException
	 */
	public static function create(string $address,int $port,int $speed=self::TCP_SPEED): self
	{
		Log::debug(sprintf('%s: + Creating connection to [%s:%d]',__METHOD__,$address,$port));

		$address = gethostbyname($address);

		/* Create a TCP/IP socket. */
		$socket = socket_create(AF_INET,SOCK_STREAM,SOL_TCP);
		if ($socket === FALSE)
			throw new SocketException(SocketException::CANT_CREATE_SOCKET,socket_strerror(socket_last_error($socket)));

		$result = socket_connect($socket,$address,$port);
		if ($result === FALSE)
			throw new SocketException(SocketException::CANT_CONNECT,socket_strerror(socket_last_error($socket)));

		return new self($socket,$speed);
	}

	/**
	 * Return the client's address
	 *
	 * @return string
	 * @todo change to __get()
	 * @deprecated
	 */
	public function getAddress(): string
	{
		return $this->address;
	}

	/**
	 * Return the port in use
	 *
	 * @return int
	 * @todo change to __get()
	 * @deprecated
	 */
	public function getPort(): int
	{
		return $this->port;
	}

	/**
	 * @param int $timeout
	 * @return int
	 * @note use socketSelect()
	 * @throws \Exception
	 */
	public function hasData(int $timeout): int
	{
		$read = [$this->connection];

		return $this->rx_left ?: $this->socketSelect($read,NULL,NULL,$timeout);
	}

	/**
	 * Read data from the socket.
	 * If we only want 1 character, we'll return the ASCII value of the data received
	 *
	 * @param int $timeout
	 * @param int $len
	 * @return int|string
	 * @throws SocketException
	 */
	public function read(int $timeout,int $len=1024)
	{
		if ($this->DEBUG)
			Log::debug(sprintf('%s: + Start [%d] (%d)',__METHOD__,$len,$timeout));

		if ($timeout AND ($this->hasData($timeout) === 0))
			return '';

		$buf = '';
		$rc = socket_recv($this->connection,$buf, $len,MSG_DONTWAIT);
		if ($this->DEBUG)
			Log::debug(sprintf('%s:  - Read [%d]',__METHOD__,$rc));

		if ($rc === FALSE)
			throw new SocketException($x=socket_last_error($this->connection),socket_strerror($x));

		// If our buffer is null, see if we have any out of band data.
		// @todo We throw an errorexception when the socket is closed by the remote I think.
		if (($rc == 0) && is_nulL($buf) && ($this->hasData(0) > 0)) {
			try {
				socket_recv($this->connection,$buf, $len,MSG_OOB);

			} catch (\Exception $e) {
				throw new SocketException($x=socket_last_error($this->connection),socket_strerror($x));
			}
		}

		return is_null($buf) ? '' : $buf;
	}

	/**
	 * Read a character from the remote.
	 * We'll buffer everything received
	 *
	 * @param int $timeout
	 * @return int
	 * @throws SocketException
	 */
	public function read_ch(int $timeout): int
	{
		if ($this->DEBUG)
			Log::debug(sprintf('%s: + Start [%d]',__METHOD__,$timeout),['rx_left'=>$this->rx_left,'rx_ptr'=>$this->rx_ptr]);

		// If our buffer is empty, we'll try and read from the remote
		if ($this->rx_left == 0) {
			if ($this->hasData($timeout) > 0) {
				try {
					if (! strlen($this->rx_buf = $this->read(0,self::RX_BUF_SIZE))) {
						if ($this->DEBUG)
							Log::debug(sprintf('%s:  - Nothing read',__METHOD__));

						return self::TTY_TIMEOUT;
					}

				} catch (\Exception $e) {
					return ($e->getCode() == 11) ? self::TTY_TIMEOUT : self::ERROR;
				}

				if ($this->DEBUG)
					Log::info(sprintf('%s:   - Read [%d] bytes',__METHOD__,strlen($this->rx_buf)));

				$this->rx_ptr = 0;
				$this->rx_left = strlen($this->rx_buf);

			} else {
				return self::TTY_TIMEOUT;
			}
		}

		$rc = ord(substr($this->rx_buf,$this->rx_ptr,1));

		$this->rx_left--;
		$this->rx_ptr++;

		if ($this->DEBUG)
			Log::debug(sprintf('%s: = Return [%x] (%c)',__METHOD__,$rc,$rc));

		return $rc;
	}

	public function rx_purge(): void
	{
		$this->rx_ptr = $this->rx_left = 0;
		$this->rx_buf = '';
	}

	/**
	 * Send data to the client
	 *
	 * @param $message
	 * @param int $timeout
	 * @param null $length
	 * @return false|int
	 * @throws \Exception
	 */
	public function send($message,int $timeout,$length=NULL)
	{
		if ($timeout AND (! $rc = $this->canSend($timeout)))
			return $rc;

		if (is_null($length))
			$length = strlen($message);

		return socket_write($this->connection,$message,$length);
	}

	/**
	 * Wait for data on a socket
	 *
	 * @param array|null $read
	 * @param array|null $write
	 * @param array|null $except
	 * @param int $timeout
	 * @return int
	 * @throws \Exception
	 */
	private function socketSelect(?array $read,?array $write,?array $except,int $timeout): int
	{
		$rc = socket_select($read,$write,$except,$timeout);

		if ($rc === FALSE)
			throw new \Exception('Socket Error: '.socket_strerror(socket_last_error()));

		if ($this->DEBUG)
			Log::debug(sprintf('Socket select returned [%d] (%d)',$rc,$timeout),['read'=>$read,'write'=>$write,'except'=>$except]);

		return $rc;
	}

	public function timer_expired(int $timer): int
	{
		return (time()>=$timer);
	}

	public function timer_rest(int $timer): int
	{
		return (($timer)-time());
	}

	public function timer_set(int $expire): int
	{
		return (time()+$expire);
	}

	/**
	 * See if we there is data waiting to collect, or if we can send
	 *
	 * @param bool $read
	 * @param bool $write
	 * @param int $timeout
	 * @return int
	 * @throws \Exception
	 */
	public function ttySelect(bool $read,bool $write, int $timeout): int
	{
		$read = $read ? [$this->connection] : NULL;
		$write = $write ? [$this->connection] : NULL;

		return $this->socketSelect($read,$write,NULL,$timeout);
	}
}