<?php

namespace App\Classes\Sock;

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

use App\Classes\Sock\Exception\{HAproxyException,SocketException};

/**
 * Class SocketClient
 *
 * @package App\Classes\Sock
 * @property int cps
 * @property int speed
 */
final class SocketClient {
	private const LOGKEY = 'SC-';

	// For deep debugging
	private const 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 TIMEOUT			= -2;
	private const ERROR				= -5;

	/** @var string Size of our TX buffer */
	private const TX_BUF_SIZE		= 0xFFFF;
	/** @var string Maximum amount of data to send at a time */
	private const TX_SIZE			= 0xFFFF;
	/** @var string Data in the TX buffer */
	private string $tx_buf = '';

	/** @var string Size of our RX buffer */
	private const RX_BUF_SIZE		= 0xFFFF;
	/** @var string Maximum amount of data to received at a time */
	private const RX_SIZE			= 0xFFFF;
	/** @var string Data in the RX buffer */
	private string $rx_buf = '';

	public function __construct (\Socket $connection) {
		$this->connection = $connection;

		if ($this->type === SOCK_STREAM) {
			socket_getsockname($connection,$this->address_local,$this->port_local);
			socket_getpeername($connection,$this->address_remote,$this->port_remote);

			// If HAPROXY is used, work get the clients address
			if (config('fido.haproxy')) {
				Log::debug(sprintf('%s:+ HAPROXY connection host [%s] on port [%d] (%s)',self::LOGKEY,$this->address_remote,$this->port_remote,$this->type));

				if ($this->read(5,12) !== "\x0d\x0a\x0d\x0a\x00\x0d\x0aQUIT\x0a")
					throw new HAproxyException('Failed to initialise HAPROXY connection');

				// Version/Command
				$vc = $this->read_ch(5);

				if (($x=($vc>>4)&0x7) !== 2)
					throw new HAproxyException(sprintf('Unknown HAPROXY version [%d]',$x));

				switch ($x=($vc&0x7)) {
					// HAPROXY internal
					case 0:
						throw new HAproxyException('HAPROXY internal health-check');

					// PROXY connection
					case 1:
						break;

					default:
						throw new HAproxyException(sprintf('HAPROXY command [%d] is not handled',$x));
				}

				// Protocol/Address Family
				$pa = $this->read_ch(5);
				$p = NULL;

				switch ($x=($pa>>4)&0x7) {
					case 1:	// AF_INET
						$p = 4;
						break;

					case 2: // AF_INET6
						$p = 6;
						break;

					default:
						throw new HAproxyException(sprintf('HAPROXY protocol [%d] is not handled',$x));
				}

				switch ($x=($pa&0x7)) {
					case 1:	// STREAM
						break;

					default:
						throw new HAproxyException(sprintf('HAPROXY address family [%d] is not handled',$x));
				}

				$len = Arr::get(unpack('n',$this->read(5,2)),1);

				// IPv4
				if (($p === 4) && ($len === 12)) {
					$src = inet_ntop($this->read(5,4));
					$dst = inet_ntop($this->read(5,4));

				} elseif (($p === 6) && ($len === 36)) {
					$src = inet_ntop($this->read(5,16));
					$dst = inet_ntop($this->read(5,16));

				} else {
					throw new HAproxyException(sprintf('HAPROXY address len [%d:%d] is not handled',$p,$len));
				}

				$src_port = unpack('n',$this->read(5,2));
				$dst_port = unpack('n',$this->read(5,2));

				$this->address_remote = $src;
				$this->port_remote = Arr::get($src_port,1);

				Log::info(sprintf('%s:! HAPROXY src [%s:%d] dst [%s:%d]',
					self::LOGKEY,
					$this->address_remote,
					$this->port_remote,
					$dst,
					Arr::get($dst_port,1),
				));
			}

			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 'rx_free':
				return self::RX_BUF_SIZE-$this->rx_left;

			case 'rx_left':
				return strlen($this->rx_buf);

			case 'tx_free':
				return self::TX_BUF_SIZE-strlen($this->tx_buf);

			case 'type':
				return socket_get_option($this->connection,SOL_SOCKET,SO_TYPE);

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

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

		$sort = collect(['AAAA','A']);

		if (filter_var($address,FILTER_VALIDATE_IP))
			$resolved = collect([[
				(($x=filter_var($address,FILTER_VALIDATE_IP,	FILTER_FLAG_IPV6)) ? 'ipv6' : 'ip')=>$address,
				'type'=>$x ? 'AAAA' : 'A'
			]]);
		else
			// 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::info(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);
	}

	/**
	 * 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
	{
		$ptr = 0;
		$num_bytes = strlen($data);

		while ($num_bytes) {
			if (self::DEBUG)
				Log::debug(sprintf('%s:- To add [%d] to the TX buffer',self::LOGKEY,$num_bytes));

			if ($num_bytes > $this->tx_free) {
				if (self::DEBUG)
					Log::debug(sprintf('%s:- TX buffer will be too full, draining...',self::LOGKEY));

				do {
					$this->buffer_flush(5);

					$n = min($this->tx_free,$num_bytes);
					$this->tx_buf = substr($data,$ptr,$n);
					$num_bytes -= $n;
					$ptr += $n;

				} while ($num_bytes);

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

		if (self::DEBUG)
			Log::debug(sprintf('%s:= TX buffer has [%d] space left',self::LOGKEY,$this->tx_free));
	}

	/**
	 * Empty our TX buffer
	 *
	 * @param int $timeout
	 * @return int
	 * @throws \Exception
	 */
	public function buffer_flush(int $timeout): int
	{
		if (self::DEBUG)
			Log::debug(sprintf('%s:+ Emptying TX buffer with [%d] chars, and timeout [%d]',self::LOGKEY,strlen($this->tx_buf),$timeout));

		$tm = $this->timer_set($timeout);
		$rc = self::OK;

		while (strlen($this->tx_buf)) {
			$tv = $this->timer_rest($tm);

			if (($rc=$this->canSend($tv)) > 0) {
				if (self::DEBUG)
					Log::debug(sprintf('%s:- Chars to send [%d]',self::LOGKEY,strlen($this->tx_buf)));

				$sent = $this->send(substr($this->tx_buf,0,self::TX_SIZE),0);

				if (self::DEBUG)
					Log::debug(sprintf('%s:- Sent [%d] chars [%s]',self::LOGKEY,$sent,Str::limit($this->tx_buf,15)));

				$this->tx_buf = substr($this->tx_buf,$sent);

			} else {
				return $rc;
			}

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

		$this->tx_purge();

		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
	{
		try {
			socket_shutdown($this->connection);
		} catch (\ErrorException $e) {
			Log::error(sprintf('%s:! Shutting down socket [%s]',self::LOGKEY,$e->getMessage()));
		}

		try {
			socket_close($this->connection);
		} catch (\ErrorException $e) {
			Log::error(sprintf('%s:! Closing socket [%s]',self::LOGKEY,$e->getMessage()));
		}

		Log::info(sprintf('%s:= Connection closed with [%s]',self::LOGKEY,$this->address_remote));
	}

	/**
	 * We have data in the buffer or on the socket
	 *
	 * @param int $timeout
	 * @return int
	 * @throws \Exception
	 */
	public function hasData(int $timeout): int
	{
		$read = [$this->connection];

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

	/**
	 * Read data, emptying from the RX buffer first, then checking the socket.
	 *
	 * @param int $timeout How long to wait for data
	 * @param int $len The amount of data we want
	 * @return string|null
	 * @throws SocketException
	 */
	public function read(int $timeout,int $len=1024): ?string
	{
		// We have data in our buffer
		if ($this->rx_left >= $len) {
			if (self::DEBUG)
				Log::debug(sprintf('%s:- Returning [%d] chars from the RX buffer',self::LOGKEY,$len));

			$result = substr($this->rx_buf,0,$len);
			$this->rx_buf = substr($this->rx_buf,strlen($result));

			return $result;
		}

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

		$buf = '';

		try {
			switch ($this->type) {
				case SOCK_STREAM:
					$recv = socket_recv($this->connection,$buf,self::RX_SIZE,MSG_DONTWAIT);
					break;

				case SOCK_DGRAM:
					$recv = socket_recvfrom($this->connection,$buf,self::RX_SIZE,MSG_DONTWAIT,$this->address_remote,$this->port_remote);
					break;

				default:
					throw new SocketException(SocketException::SOCKET_ERROR,sprintf('Unhandled socket type: %s',$this->type));
			}

		} 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 we got no data, we'll send whatever is left in the buffer
		if ($recv === FALSE) {
			// If we have something in the buffer, we'll send it
			if ($this->rx_left) {
				if (self::DEBUG)
					Log::debug(sprintf('%s:- Network read return an error, returning final [%d] chars from the RX buffer',self::LOGKEY,strlen($this->rx_buf)));

				$result = $this->rx_buf;
				$this->rx_buf = '';

				return $result;
			}

			Log::debug(sprintf('%s:! Request to read [%d] chars resulted in no data',self::LOGKEY,$len));
			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 (($recv === 0) && is_null($buf) && ($this->hasData(0) > 0) && $this->type === SOCK_STREAM) {
			try {
				socket_recv($this->connection,$buf,$len,MSG_OOB);

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

		$this->rx_buf .= $buf;

		if (self::DEBUG)
			Log::debug(sprintf('%s:- Added [%d] chars to the RX buffer',self::LOGKEY,strlen($buf)),['rx_buf'=>hex_dump($this->rx_buf)]);

		// Loop again and return the data, now that it is in the RX buffer
		return $this->read($timeout,$len);
	}

	/**
	 * 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->hasData($timeout) > 0) {
			$ch = $this->read($timeout,1);

		} else {
			return self::TIMEOUT;
		}

		return ord($ch);
	}

	public function rx_purge(): void
	{
		if (self::DEBUG)
			Log::debug(sprintf('%s:+ Discarding [%d] chars from the RX buffer',self::LOGKEY,strlen($this->tx_buf)));

		$this->rx_buf = '';
	}

	/**
	 * Clear our TX buffer
	 */
	public function tx_purge(): void
	{
		if (self::DEBUG)
			Log::debug(sprintf('%s:+ Discarding [%d] chars from the TX buffer',self::LOGKEY,strlen($this->tx_buf)));

		$this->tx_buf = '';
	}

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

		if (self::DEBUG)
			Log::debug(sprintf('%s:- Sending [%d] chars [%s]',self::LOGKEY,strlen($message),Str::limit($message,15)));

		switch ($this->type) {
			case SOCK_STREAM:
				return socket_write($this->connection,$message,strlen($message));

			case SOCK_DGRAM:
				return socket_sendto($this->connection,$message,strlen($message),0,$this->address_remote,$this->port_remote);

			default:
				throw new SocketException(SocketException::SOCKET_ERROR,sprintf('Unhandled socket type: %s',$this->type));
		}
	}

	/**
	 * 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 (self::DEBUG)
			Log::debug(sprintf('%s:= Socket select returned [%d] with timeout (%d)',self::LOGKEY,$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 there is data waiting to collect, or if we can send
	 *
	 * @param bool $read
	 * @param bool $write
	 * @param int $timeout
	 * @return int
	 * @throws \Exception
	 * @deprecated use canSend or hasData
	 */
	public function ttySelect(bool $read,bool $write, int $timeout): int
	{
		if ($this->rx_left) {
			if (self::DEBUG)
				Log::debug(sprintf('%s:= We still have [%d] chars in the RX buffer.',self::LOGKEY,$this->rx_left));

			return 1;
		}

		$read = $read ? [$this->connection] : NULL;
		$write = $write ? [$this->connection] : NULL;

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