<?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 {
	private const LOGKEY = 'SC-';

	// 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) {
		$this->connection = $connection;

		if ($this->type === 'TCP') {
			socket_getsockname($connection,$this->address_local,$this->port_local);
			socket_getpeername($connection,$this->address_remote,$this->port_remote);
			Log::info(sprintf('%s:+ Connection host [%s] on port [%d] (%s)',self::LOGKEY,$this->address_remote,$this->port_remote,$this->type));
		}
	}

	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);

			case 'type':
				switch ($x=socket_get_option($this->connection,SOL_SOCKET,SO_TYPE)) {
					case SOCK_STREAM:
						return 'TCP';
					case SOCK_DGRAM:
						return 'UDP';

					default:
						return sprintf('UNKNOWN [%d]',$x);
				}

			default:
				throw new \Exception(sprintf('%s:! Unknown key [%s]:',self::LOGKEY,$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]:',self::LOGKEY,$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)',self::LOGKEY,$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]',self::LOGKEY,$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]',self::LOGKEY,$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]',self::LOGKEY,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]',self::LOGKEY,$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]',self::LOGKEY,$restsize));
				$rc = $this->send(substr($this->tx_buf,$tx_ptr,$restsize),0);

				if ($this->DEBUG)
					Log::debug(sprintf('%s: - Sent [%d] (%s)',self::LOGKEY,$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]',self::LOGKEY,$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
	 * @return static
	 * @throws SocketException
	 */
	public static function create(string $address,int $port): self
	{
		Log::debug(sprintf('%s:+ Creating connection to [%s:%d]',self::LOGKEY,$address,$port));
		$sort = collect(['AAAA','A']);

		// We only look at AAAA/A records
		$resolved = collect(dns_get_record($address,DNS_AAAA|DNS_A))
			->filter(function($item) use ($sort) { return $sort->search(Arr::get($item,'type')) !== FALSE; })
			->sort(function($item) use ($sort) { return $sort->search(Arr::get($item,'type')); });

		if (! $resolved->count())
			throw new SocketException(SocketException::CANT_CONNECT,sprintf('%s doesnt resolved to an IPv4/IPv6 address',$address));

		$result = FALSE;

		foreach ($resolved as $address) {
			try {
				$try = Arr::get($address,Arr::get($address,'type') == 'AAAA' ? 'ipv6' : 'ip');
				if (! $try)
					continue;

				Log::alert(sprintf('%s:  - Trying [%s:%d]',self::LOGKEY,$try,$port));

				/* Create a TCP/IP socket. */
				$socket = socket_create(Arr::get($address,'type') == 'AAAA' ? AF_INET6 : 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,$try,$port);
				break;

			} catch (\ErrorException $e) {
				// If 'Cannot assign requested address'
				if (socket_last_error($socket) == 99)
					continue;

				throw new SocketException(SocketException::CANT_CONNECT,socket_strerror(socket_last_error($socket)));
			}
		}

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

		return new self($socket);
	}

	/**
	 * @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.
	 *
	 * @param int $timeout
	 * @param int $len
	 * @param int $size
	 * @return string
	 * @throws SocketException
	 */
	public function read(int $timeout,int $len=1024,int $size=1024): string
	{
		if ($this->DEBUG)
			Log::debug(sprintf('%s:+ Start [%d] (%d)',self::LOGKEY,$len,$timeout));

		// We have data in our buffer
		if ($this->rx_left >= $len) {
			$result = substr($this->rx_buf,$this->rx_ptr,$len);
			$this->rx_ptr += $len;
			$this->rx_left -= $len;

			if ($this->rx_left === 0) {
				$this->rx_buf = '';
				$this->rx_ptr = 0;
			}

			return $result;
		}

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

		$buf = '';
		try {
			if ($this->type === 'TCP')
				$rc = socket_recv($this->connection,$buf, $size,MSG_DONTWAIT);

			else {
				$rc = socket_recvfrom($this->connection,$buf, $size,MSG_DONTWAIT,$this->address_remote,$this->port_remote);
			}

		} catch (\Exception $e) {
			Log::error(sprintf('%s: - socket_recv Exception [%s]',self::LOGKEY,$e->getMessage()));

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

		if ($this->DEBUG)
			Log::debug(sprintf('%s: - Read [%d]',self::LOGKEY,$rc));

		if ($rc === FALSE) {
			// If we have something in the buffer, we'll send it
			if ($this->rx_left && $this->rx_left < $len) {
				$return = substr($this->rx_buf,$this->rx_ptr);

				$this->rx_left = 0;
				$this->rx_ptr = 0;
				$this->rx_buf = '';

				return $return;
			}

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

		$this->rx_buf .= $buf;
		$this->rx_left += strlen($buf);

		// 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 $this->read($timeout,$len,$size);
	}

	/**
	 * Read a character from the remote.
	 * We'll buffer everything received
	 *
	 * @param int $timeout
	 * @return int
	 * @throws \Exception
	 */
	public function read_ch(int $timeout): int
	{
		if ($this->DEBUG)
			Log::debug(sprintf('%s:+ Start [%d] - rx_left[%d], rx_ptr[%d]',self::LOGKEY,$timeout,$this->rx_left,$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',self::LOGKEY));

						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',self::LOGKEY,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)',self::LOGKEY,$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 ($this->type === 'TCP')
			? socket_write($this->connection,$message,$length)
			: socket_sendto($this->connection,$message,$length,0,$this->address_remote,$this->port_remote);
	}

	/**
	 * 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);
	}
}