<?php

namespace App\Classes\Protocol;

use Carbon\Carbon;
use Exception;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use League\Flysystem\UnreadableFileException;

use App\Classes\Protocol as BaseProtocol;
use App\Classes\Sock\SocketClient;
use App\Classes\Sock\SocketException;
use App\Models\{Address,Setup};

final class Binkp extends BaseProtocol
{
	private const LOGKEY = 'PB-';

	/* -- */
	private const BP_PROT		= 'binkp';					/* protocol text */
	private const BP_VERSION	= '1.1';					/* version implemented */
	private const BP_BLKSIZE	= 4096;						/* block size */
	private const BP_TIMEOUT	= 300;						/* session timeout */
	private const MAX_BLKSIZE	= 0x7fff;					/* max block size */

	/* options */
	private const O_NO			= 0;						/* I/They dont a capability? */
	private const O_WANT		= 1;						/* I want a capability, but can be persuaded */
	private const O_WE			= 2;						/* We agree on a capability */
	private const O_THEY		= 4;						/* They want a capability */
	private const O_NEED		= 8;						/* I want a capability, and wont compromise */
	private const O_EXT			= 16;
	private const O_YES			= 32;

	/* messages */
	private const BPM_NONE		= 99;						/* No available data */
	private const BPM_DATA		= 98;						/* Binary data */
	private const BPM_NUL		= 0;						/* Site information */
	private const BPM_ADR		= 1;						/* List of addresses */
	private const BPM_PWD		= 2;						/* Session password */
	private const BPM_FILE		= 3;						/* File information */
	private const BPM_OK		= 4;						/* Password was acknowlged (data ignored) */
	private const BPM_EOB		= 5;						/* End Of Batch (data ignored) */
	private const BPM_GOT		= 6;						/* File received */
	private const BPM_ERR		= 7;						/* Misc errors */
	private const BPM_BSY		= 8;						/* All AKAs are busy */
	private const BPM_GET		= 9;						/* Get a file from offset */
	private const BPM_SKIP		= 10;						/* Skip a file (RECEIVE LATER) */
	private const BPM_RESERVED	= 11;						/* Reserved for later */
	private const BPM_CHAT		= 12;						/* For chat */
	private const BPM_MIN		= self::BPM_NUL;			/* Minimal message type value */
	private const BPM_MAX		= self::BPM_CHAT;			/* Maximal message type value */

	private const SE_BASE		= 1;
	private const SE_INIT		= (1<<self::SE_BASE);		/* 0000 0001 Are we in initialise mode */
	private const SE_SENTEOB	= (1<<self::SE_BASE+1);		/* 0000 0010 Have we sent our EOB */
	private const SE_RECVEOB	= (1<<self::SE_BASE+2);		/* 0000 0100 Have we received EOB */
	private const SE_DELAYEOB	= (1<<self::SE_BASE+3);		/* 0000 1000 Delay sending M_EOB message until remote's one if remote is binkp/1.0 and pretends to have FREQ on us */
	private const SE_WAITGET	= (1<<self::SE_BASE+4);		/* 0001 0000 Wait for GET before sending a file */
	private const SE_WAITGOT	= (1<<self::SE_BASE+5);		/* 0010 0000 We are waiting for a GOT from the remote */
	private const SE_SENDFILE	= (1<<self::SE_BASE+6);		/* 0100 0000 Are we sending a file */
	private const SE_NOFILES	= (1<<self::SE_BASE+7);		/* 1000 0000 We have no more files to send */

	private string $md_challenge;							/* The MD5 challenge with the remote system */
	private int $is_msg;
	private int $mib;
	private int $rc;
	private int $error;

	private int $rx_ptr;	// @todo Whats the point of this? It seems its only the size of $rx_buf?
	private int $rx_size;
	private string $rx_buf = '';

	private ?Collection $mqueue;
	private string $tx_buf;
	private int $tx_ptr;
	private int $tx_left;

	/* BINK COMMANDS */
	private const M_NUL 		= 0;
	private const M_ADR 		= 1;
	private const M_PWD 		= 2;
	private const M_FILE 		= 3;
	private const M_OK	 		= 4;
	private const M_EOB 		= 5;
	private const M_GOTSKIP		= 6;
	private const M_ERR			= 7;
	private const M_BSY			= 8;
	private const M_GET			= 9;
	private const M_CHAT		= 12;

	/**
	 * Incoming BINKP session
	 *
	 * @param SocketClient $client
	 * @return int|null
	 * @throws SocketException
	 * @throws Exception
	 */
	public function onConnect(SocketClient $client): ?int
	{
		// If our parent returns a PID, we've forked
		if (! parent::onConnect($client)) {
			Log::withContext(['pid'=>getmypid()]);

			$this->session(self::SESSION_BINKP,$client,(new Address));
			$this->client->close();

			Log::info(sprintf('%s:= onConnect - Connection closed [%s]',self::LOGKEY,$client->address_remote));
			exit(0);
		}

		return NULL;
	}

	/**
	 * @throws Exception
	 */
	private function binkp_hs(): void
	{
		if ($this->DEBUG)
			Log::debug(sprintf('%s:+ binkp_hs',self::LOGKEY));

		if (! $this->originate && ($this->setup->opt_md&self::O_WANT)) {
			$random_key = random_bytes(8);
			$this->md_challenge = md5($random_key,TRUE);
			$this->msgs(self::BPM_NUL,sprintf('OPT CRAM-MD5-%s',md5($random_key,FALSE)));
		}

		$this->msgs(self::BPM_NUL,sprintf('SYS %s',$this->setup->system_name));
		$this->msgs(self::BPM_NUL,sprintf('ZYZ %s',$this->setup->sysop));
		$this->msgs(self::BPM_NUL,sprintf('LOC %s',$this->setup->location));
		$this->msgs(self::BPM_NUL,sprintf('NDL %d,TCP,BINKP',$this->client->speed));
		$this->msgs(self::BPM_NUL,sprintf('TIME %s',Carbon::now()->toRfc2822String()));
		$this->msgs(self::BPM_NUL,
			sprintf('VER %s-%s %s/%s',config('app.name'),$this->setup->version,self::BP_PROT,self::BP_VERSION));

		if ($this->originate) {
			$this->msgs(self::BPM_NUL,
				sprintf('OPT%s%s%s%s%s%s',
					($this->setup->opt_nda)			? ' NDA' : '',
					($this->setup->opt_nr&self::O_WANT)		? ' NR' : '',
					($this->setup->opt_nd&self::O_THEY)		? ' ND' : '',
					($this->setup->opt_mb&self::O_WANT)		? ' MB' : '',
					($this->setup->opt_cr&self::O_WE)		? ' CRYPT' : '',
					($this->setup->opt_cht&self::O_WANT)	? ' CHAT' : ''));
		}

		// If we are originating, we'll show the remote our address in the same network
		if ($this->originate) {
			if ($this->setup->optionGet(Setup::O_HIDEAKA)) {
				$addresses = collect();

				foreach ($this->node->aka_remote_authed as $ao)
					$addresses = $addresses->merge($this->setup->system->match($ao->zone,Address::NODE_ZC|Address::NODE_RC|Address::NODE_NC|Address::NODE_HC|Address::NODE_ACTIVE|Address::NODE_PVT|Address::NODE_POINT));

				$addresses = $addresses->unique();

				Log::debug(sprintf('%s: - Presenting limited AKAs [%s]',self::LOGKEY,$addresses->pluck('ftn')->join(',')));

			} else {
				$addresses = $this->setup->system->addresses;

				Log::debug(sprintf('%s: - Presenting ALL our AKAs [%s]',self::LOGKEY,$addresses->pluck('ftn')->join(',')));
			}

			$this->msgs(self::BPM_ADR,$this->setup->system->addresses->pluck('ftn')->join(' '));
		}
	}

	/**
	 * @return int
	 */
	private function binkp_hsdone(): int
	{
		if ($this->DEBUG)
			Log::debug(sprintf('%s:+ binkp_hsdone',self::LOGKEY));

		if (($this->setup->opt_nd == self::O_WE) || ($this->setup->opt_nd == self::O_THEY))
			$this->setup->opt_nd = self::O_NO;

		if (! $this->setup->phone)
			$this->setup->phone = '-Unpublished-';

		if (! $this->optionGet(self::O_PWD) || ($this->setup->opt_md != self::O_YES))
			$this->setup->opt_cr = self::O_NO;

		if (($this->setup->opt_cr&self::O_WE) && ($this->setup->opt_cr&self::O_THEY)) {
			dump('Enable crypting messages');

			/*
			$this->setup->opt_cr = O_YES;
			if ( bp->to ) {
				init_keys( bp->keys_out, $this->node->password );
				init_keys( bp->keys_in, "-" );
				keys = bp->keys_in;
			} else {
				init_keys( bp->keys_in, $this->node->password );
				init_keys( bp->keys_out, "-" );
				keys = bp->keys_out;
			}
			for( p = $this->node->password; *p; p++ ) {
				update_keys( keys, (int) *p );
			}
			*/
		}

		// @todo Implement max incoming sessions and max incoming session for the same node

		// We have no mechanism to support chat
		if ($this->setup->opt_cht&self::O_WANT)
			Log::warning(sprintf('%s: ! We cant do chat',self::LOGKEY));

		if ($this->setup->opt_nd&self::O_WE || ($this->originate && ($this->setup->opt_nr&self::O_WANT) && $this->node->get_versionint() > 100))
			$this->setup->opt_nr |= self::O_WE;

		if (($this->setup->opt_cht&self::O_WE) && ($this->setup->opt_cht&self::O_WANT))
			$this->setup->opt_cht = self::O_YES;

		$this->setup->opt_mb = (($this->node->get_versionint() > 100) || ($this->setup->opt_mb&self::O_WE)) ? self::O_YES : self::O_NO;

		if ($this->node->get_versionint() > 100)
			$this->sessionClear(self::SE_DELAYEOB);

		$this->mib = 0;
		$this->sessionClear(self::SE_INIT);

		Log::info(sprintf('%s: - Session: BINKP/%d.%d - NR:%d, ND:%d, MD:%d, MB:%d, CR:%d, CHT:%d',
			self::LOGKEY,
			$this->node->ver_major,
			$this->node->ver_minor,
			$this->setup->opt_nr,
			$this->setup->opt_nd,
			$this->setup->opt_md,
			$this->setup->opt_mb,
			$this->setup->opt_cr,
			$this->setup->opt_cht
		));

		return 1;
	}

	private function binkp_init(): int
	{
		if ($this->DEBUG)
			Log::debug(sprintf('%s:+ binkp_init',self::LOGKEY));

		$this->sessionSet(self::SE_INIT);

		$this->is_msg = -1;
		$this->mib = 0;
		$this->error = 0;
		$this->mqueue = collect();

		$this->rx_ptr = 0;
		$this->rx_size = -1;

		$this->tx_buf = '';
		$this->tx_left = 0;
		$this->tx_ptr = 0;

		for ($x=0;$x<count($this->setup->binkp_options);$x++) {
			switch (strtolower($this->setup->binkp_options[$x])) {
				case 'p':										/* Force password digest */
					$this->setup->opt_md |= self::O_NEED;

				case 'm':										/* Want password digest */
					$this->setup->opt_md |= self::O_WANT;
					break;

				case 'c':										/* Can crypt */
					$this->setup->opt_cr |= self::O_WE;
					break;

				case 'd':										/* No dupes mode */
					$this->setup->opt_nd |= self::O_NO; /*mode?O_THEY:O_NO;*/

				case 'r':										/* Non-reliable mode */
					$this->setup->opt_nr |= ($this->originate ? self::O_WANT : self::O_NO);
					break;

				case 'b':										/* Multi-batch mode */
					$this->setup->opt_mb |= self::O_WANT;
					break;

				case 't':										/* Chat - not implemented */
					//$this->setup->opt_cht |= self::O_WANT;
					break;

				default:
					Log::error(sprintf('%s: ! binkp_init ERROR - Unknown BINKP option [%s]',self::LOGKEY,$this->setup->binkp_options[$x]));
			}
		}

		return self::OK;
	}

	/**
	 * @throws Exception
	 */
	private function binkp_recv(): int
	{
		if ($this->DEBUG)
			Log::debug(sprintf('%s:+ binkp_recv',self::LOGKEY));

		$blksz = $this->rx_size == -1 ? BinkpMessage::BLK_HDR_SIZE : $this->rx_size;
		Log::debug(sprintf('%s: - binkp_recv blksize [%d] rx_size [%d].',self::LOGKEY,$blksz,$this->rx_size));

		if ($blksz !== 0) {
			try {
				$this->rx_buf .= $this->client->read(0,$blksz-$this->rx_ptr);

			} catch (SocketException $e) {
				if ($e->getCode() == 11) {
					// @todo We maybe should count these and abort if there are too many?
					if ($this->DEBUG)
						Log::debug(sprintf('%s: - binkp_recv Socket EAGAIN',self::LOGKEY));

					return 1;
				}
				Log::error(sprintf('%s: - binkp_recv Exception [%s].',self::LOGKEY,$e->getCode()));

				$this->socket_error = $e->getMessage();
				$this->error = 1;

				return 0;
			}

			if (strlen($this->rx_buf) == 0) {
				// @todo Check that this is correct.
				Log::debug(sprintf('%s: - binkp_recv Was the socket closed by the remote?',self::LOGKEY));
				$this->error = -2;

				return 0;
			}

			/*
			if ($this->setup->opt_cr == self::O_YES ) {
				//decrypt_buf( (void *) &bp->rx_buf[bp->rx_ptr], (size_t) readsz, bp->keys_in );
			}
			*/
		}

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

		if ($this->DEBUG)
			Log::debug(sprintf('%s: - binkp_recv rx_ptr [%d] blksz [%d].',self::LOGKEY,$this->rx_ptr,$blksz));

		/* Received complete block */
		if ($this->rx_ptr == $blksz) {
			/* Header */
			if ($this->rx_size == -1 ) {
				$this->is_msg = ord(substr($this->rx_buf,0,1)) >> 7;
				$this->rx_size = ((ord(substr($this->rx_buf,0,1))&0x7f) << 8 )+ord(substr($this->rx_buf,1,1));
				$this->rx_ptr = 0;

				if ($this->DEBUG)
					Log::debug(sprintf('%s: - binkp_recv HEADER, is_msg [%d], rx_size [%d]',self::LOGKEY,$this->is_msg,$this->rx_size));

				if ($this->rx_size == 0)
					goto ZeroLen;

				$rc = 1;

			/* Next block */
			} else {
			ZeroLen:
				Log::debug(sprintf('%s: - binkp_recv NEXT BLOCK, is_msg [%d]',self::LOGKEY,$this->is_msg));

				if ($this->is_msg) {
					$this->mib++;

					/* Handle zero length block */
					if ($this->rx_size == 0 ) {
						Log::debug(sprintf('%s:- binkp_recv Zero length msg - dropped',self::LOGKEY));
						$this->rx_size = -1;
						$this->rx_ptr = 0;
						$this->rx_buf = '';

						return 1;
					}

					Log::debug(sprintf('%s: - binkp_recv BUFFER [%d]',self::LOGKEY,strlen($this->rx_buf)));

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

					if ($rc > self::BPM_MAX) {
						Log::error(sprintf('%s: ! binkp_recv Unknown Message [%s] (%d)',self::LOGKEY,$this->rx_buf,strlen($this->rx_buf)));
						$rc = 1;

					} else {
						//DEBUG(('B',2,"rcvd %s '%s'%s",mess[rc],bp->rx_buf + 1,CRYPT(bps)));	//@todo CRYPT
						$data = substr($this->rx_buf,1);
						switch ($rc) {
							case self::M_ADR:
								$rc = $this->M_adr($data);
								break;

							case self::M_EOB:
								$rc = $this->M_eob($data);
								break;

							case self::M_NUL:
								$rc = $this->M_nul($data);
								break;

							case self::M_PWD:
								$rc = $this->M_pwd($data);
								break;

							case self::M_ERR:
								$rc = $this->M_err($data);
								break;

							case self::M_FILE:
								$rc = $this->M_file($data);
								break;

							case self::M_GET:
								$rc = $this->M_get($data);
								break;

							case self::M_GOTSKIP:
								$rc = $this->M_gotskip($data);
								break;

							case self::M_OK:
								$rc = $this->M_ok($data);
								break;

							case self::M_CHAT:
								$rc = $this->M_chat($data);
								break;

							default:
								Log::error(sprintf('%s: ! binkp_recv Command not implemented [%d]',self::LOGKEY,$rc));
								$rc = 1;
						}
					}

				} else {
					if ($this->recv->fd) {
						try {
							$rc = $this->recv->write($this->rx_buf);

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

							$this->recv->close();
							$this->msgs(self::BPM_SKIP,$this->recv->name_size_time);
							$rc = 1;
						}

						if ($this->recv->filepos == $this->recv->size) {
							Log::info(sprintf('%s: - Finished receiving file [%s] with size [%d]',self::LOGKEY,$this->recv->name,$this->recv->size));

							$this->msgs(self::BPM_GOT,$this->recv->name_size_time);
							$this->recv->close();
							$rc = 1;
						}

					} else {
						Log::critical(sprintf('%s: - binkp_recv Ignoring data block', self::LOGKEY));
						$rc = 1;
					}
				}

				$this->rx_ptr = 0;
				$this->rx_size = -1;
			}

			$this->rx_buf = '';

		} else {
			$rc = 1;
		}

		Log::debug(sprintf('%s:= binkp_recv [%d]',self::LOGKEY,$rc));

		return $rc;
	}

	/**
	 * @throws Exception
	 */
	private function binkp_send(): int
	{
		Log::debug(sprintf('%s:+ binkp_send - tx_left [%d]',self::LOGKEY,$this->tx_left));

		if ($this->tx_left == 0 ) {                   /* tx buffer is empty */
			$this->tx_ptr = $this->tx_left = 0;

			if ($this->DEBUG)
				Log::debug(sprintf('%s: - binkp_send msgs [%d]',self::LOGKEY,$this->mqueue->count()));

			if ($this->mqueue->count()) {                      /* there are unsent messages */
				while ($msg = $this->mqueue->shift()) {
					if (($msg->len+$this->tx_left) > self::MAX_BLKSIZE) {
						break;
					}

					$this->tx_buf .= $msg->msg;
					$this->tx_left += $msg->len;
				}

			} elseif ($this->sessionGet(self::SE_SENDFILE) && $this->send->fd && (! $this->sessionGet(self::SE_WAITGET))) {
				$data = '';

				try {
					$data = $this->send->read(self::BP_BLKSIZE);

				} catch (UnreadableFileException) {
					$this->send->close(FALSE);
					$this->sessionClear(self::SE_SENDFILE);

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

				if ($data) {
					$this->tx_buf .= BinkpMessage::mkheader(strlen($data));
					$this->tx_buf .= $data;

					/*
					if ($this->setup->opt_cr == self::O_YES) {
						encrypt_buf($this->tx_buf,($data + BinkpMessage::BLK_HDR_SIZE),$this->keys_out);
					}
					*/

					$this->tx_left = strlen($data)+BinkpMessage::BLK_HDR_SIZE;
				}

				// @todo should this be less than BP_BLKSIZE? Since a read could return a blocksize and it could be the end of the file?
				if ($data < self::BP_BLKSIZE && $this->send->filepos == $this->send->size) {
					$this->sessionSet(self::SE_WAITGOT);
					$this->sessionClear(self::SE_SENDFILE);
				}
        	}

		} else {
			try {
				$rc = $this->client->send(substr($this->tx_buf,$this->tx_ptr,$this->tx_left),self::BP_TIMEOUT);

			} catch (Exception $e) {
				if ($e->getCode() == 11)
					return 1;

				$this->socket_error = $e->getMessage();
				Log::error(sprintf('%s:! binkp_send - ERROR [%s]',self::LOGKEY,$e->getMessage()));
				return 0;
			}

			$this->tx_ptr += $rc;
			$this->tx_left -= $rc;

			if (! $this->tx_left) {
				$this->tx_buf = '';
				$this->tx_ptr = 0;
			}
		}

		Log::debug(sprintf('%s:= binkp_send [1]',self::LOGKEY));

	    return 1;
	}

	private function file_parse(string $str): ?array
	{
		if ($this->DEBUG)
			Log::debug(sprintf('%s:+ file_parse [%s]',self::LOGKEY,$str));

		$name = $this->strsep($str,' ');
		$size = $this->strsep($str,' ');
		$time = $this->strsep($str,' ');
		$offs = $this->strsep($str,' ');

		if ($name && $size && $time) {
			return [
				'file'=>['name'=>$name,'size'=>$size,'mtime'=>$time],
				'offs'=>$offs
			];
		}

		return NULL;
	}

	/**
	 * Add a BINKP control message to the queue
	 *
	 * @param string $id
	 * @param string $msg_body
	 * @return void
	 */
	private function msgs(string $id,string $msg_body): void
	{
		Log::debug(sprintf('%s:+ msgs [%d:%s]',self::LOGKEY,$id,$msg_body));

		$this->mqueue->push(new BinkpMessage($id,$msg_body));

		/*
		if ($this->setup->opt_cr == self::O_YES) {
			//$this->encrypt_buf($this->bps->mqueue[$this->nmsgs]->msg,$this->bps->mqueue[$this->nmsgs]->len,$this->bps->keys_out);
		}
		*/

		$this->mib++;
	}

	/**
	 * @throws Exception
	 */
	private function M_adr(string $buf): int
	{
		Log::debug(sprintf('%s:+ M_adr [%s]',self::LOGKEY,$buf));

		$buf = $this->skip_blanks($buf);
    	$rc = 0;

		while(($rem_aka = $this->strsep($buf,' '))) {
			Log::info(sprintf('%s: - Parsing AKA [%s]',self::LOGKEY,$rem_aka));

			try {
				if (! ($o = Address::findFTN($rem_aka))) {
					Log::debug(sprintf('%s: ? AKA is UNKNOWN [%s]',self::LOGKEY,$rem_aka));

					continue;
				}

			} catch (Exception) {
				Log::error(sprintf('%s: ! AKA is INVALID [%s]',self::LOGKEY,$rem_aka));

				$this->msgs(self::BPM_ERR,sprintf('Bad address %s',$rem_aka));
				$this->rc = self::S_FAILURE;

				return 0;
			}

			// Check if the remote has our AKA
			if ($this->setup->system->addresses->pluck('ftn')->search($rem_aka) !== FALSE) {
				Log::error(sprintf('%s: ! AKA is OURS [%s]',self::LOGKEY,$rem_aka));

				$this->msgs(self::BPM_ERR,sprintf('Sorry that is my AKA [%s]',$rem_aka));
				$this->rc = self::S_FAILURE;

				return 0;
			}

			// @todo lock nodes
			$this->node->ftn = $o;

			$rc = $this->node->aka_num;
		}

		if ($rc == 0) {
			Log::error(sprintf('%s: ! All AKAs [%d] are busy',self::LOGKEY,$this->node->aka_num));

			$this->msgs( self::BPM_BSY,'All AKAs are busy');
			$this->rc = ($this->originate ? self::S_REDIAL|self::S_ADDTRY : self::S_BUSY);

			return 0;
		}

		if ($this->originate) {
			if (! $this->node->originate_check()) {
				Log::error(sprintf('%s: ! We didnt get who we called?',self::LOGKEY));

				$this->msgs( self::BPM_ERR,'Sorry, you are not who I expected');
				$this->rc = self::S_FAILURE|self::S_ADDTRY;

				return 0;
			}

			if ($this->md_challenge) {
				$this->msgs(self::BPM_PWD,sprintf('CRAM-MD5-%s',$this->node->get_md5chal($this->md_challenge)));

			} elseif ($this->setup->opt_md == self::O_YES ) {
				$this->msgs(self::BPM_ERR,'Can\'t use plaintext password');
				$this->rc = self::S_FAILURE|self::S_ADDTRY;

				return 0;

			} else {
				$this->msgs(self::BPM_PWD,$this->node->password);
			}
		}

		if (! $this->node->aka_num)
			$this->optionClear(self::O_PWD);
		else
			$this->optionSet(self::O_PWD);

		// If we are not the originator, we'll show our addresses in common.
		if (! $this->originate)
			$this->msgs(self::BPM_ADR,$this->our_addresses()->pluck('ftn')->join(' '));

    	return 1;
	}

	private function M_chat(string $buf): int
	{
		Log::debug(sprintf('%s:+ M_chat [%s]',self::LOGKEY,$buf));

		if ($this->setup->opt_cht == self::O_YES) {
			Log::error(sprintf('%s: ! We cannot do chat',self::LOGKEY));

		} else {
			Log::error(sprintf('%s: ! We got a chat message, but chat is disabled',self::LOGKEY));
		}

		return 1;
	}

	/**
	 * We received EOB from the remote.
	 *
	 * @throws Exception
	 */
	private function M_eob(string $buf): int
	{
		Log::debug(sprintf('%s:+ M_eob [%s]',self::LOGKEY,$buf));

		if ($this->recv->fd)
			$this->recv->close();

		$this->sessionSet(self::SE_RECVEOB);
		$this->sessionClear(self::SE_DELAYEOB);

		if (! $this->send->total_count && $this->sessionGet(self::SE_NOFILES)) {
			// Add our mail to the queue if we have authenticated
			if ($this->node->aka_authed)
				foreach ($this->node->aka_remote_authed as $ao) {
					Log::debug(sprintf('%s: - M_eob Checking for any new mail to [%s]',self::LOGKEY,$ao->ftn));
					$this->send->mail($ao);
				}

			if ($this->send->total_count)
				$this->sessionClear(self::SE_NOFILES|self::SE_SENTEOB);
		}

		return 1;
	}

	/**
	 * @throws Exception
	 */
	private function M_err(string $buf): int
	{
		Log::debug(sprintf('%s:+ M_err [%s]',self::LOGKEY,$buf));

		$this->error_close();
		$this->rc = ($this->originate ? (self::S_REDIAL|self::S_ADDTRY) : self::S_BUSY);

		return 0;
	}

	/**
	 * @throws Exception
	 */
	private function M_file(string $buf): int
	{
		Log::debug(sprintf('%s:+ M_file [%s]',self::LOGKEY,$buf));

		if ($this->sessionGet(self::SE_SENTEOB|self::SE_RECVEOB))
			$this->sessionClear(self::SE_SENTEOB);

		$this->sessionClear(self::SE_RECVEOB);

		if ($this->recv->fd)
			$this->recv->close();

		if (! $file = $this->file_parse($buf)) {
			Log::error(sprintf('%s: - UNPARSABLE file info [%s]',self::LOGKEY,$buf));
			$this->msgs(self::BPM_ERR,sprintf('M_FILE: unparsable file info: "%s"',$buf));

			if ($this->sessionGet(self::SE_SENDFILE))
				$this->send->close(FALSE);

			$this->rc = ($this->originate ? self::S_REDIAL|self::S_ADDTRY : self::S_BUSY);

			return 0;
		}

		// In NR mode, when we got -1 for the file offsite, the reply to our get will confirm our requested offset.
		if ($this->recv->name && ! strncasecmp(Arr::get($file,'file.name'),$this->recv->name,self::MAX_PATH)
			&& $this->recv->mtime == Arr::get($file,'file.mtime')
			&& $this->recv->size == Arr::get($file,'file.size')
			&& $this->recv->filepos == $file['offs'])
		{
			$this->recv->open($this->node->address,$file['offs']<0);

			return 1;
		}

		$this->recv->new($file['file']);

		try {
			switch ($this->recv->open($this->node->address,$file['offs']<0)) {
				case self::FOP_ERROR:
					Log::error(sprintf('%s: ! File ERROR',self::LOGKEY));

				case self::FOP_SUSPEND:
					Log::info(sprintf('%s: - File Suspended',self::LOGKEY));
					$this->msgs(self::BPM_SKIP,$this->recv->name_size_time);

					break;

				case self::FOP_SKIP:
					Log::info(sprintf('%s: - File Skipped',self::LOGKEY));
					$this->msgs(self::BPM_GOT,$this->recv->name_size_time);

					break;

				case self::FOP_OK:
					Log::debug(sprintf('%s: - Getting file from [%d]',self::LOGKEY,$file['offs']));

					if ($file['offs'] != -1) {
						if (! ($this->setup->opt_nr&self::O_THEY)) {
							$this->setup->opt_nr |= self::O_THEY;
						}

						break;
					}

				case self::FOP_CONT:
					Log::debug(sprintf('%s: - Continuing file [%s] (%ld)',self::LOGKEY,$this->recv->name,$file['offs']));

					$this->msgs(self::BPM_GET,sprintf('%s %ld',$this->recv->name_size_time,($file['offs'] < 0) ? 0 : $file['offs']));

					break;
			}

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

			$this->msgs(self::BPM_SKIP,$this->recv->name_size_time);

			// Close the file, since we had an error opening it.
			if ($this->recv->fd)
				$this->recv->close();
		}

		return 1;
	}

	/**
	 * @throws Exception
	 */
	private function M_get(string $buf): int
	{
		Log::debug(sprintf('%s:+ M_get [%s]',self::LOGKEY,$buf));

		if ($file = $this->file_parse($buf)) {
			if ($this->sessionGet(self::SE_SENDFILE)
				&& $this->send->sendas
				&& ! strncasecmp(Arr::get($file,'file.name'),$this->send->sendas,self::MAX_PATH)
				&& $this->send->mtime == Arr::get($file,'file.mtime')
				&& $this->send->size == Arr::get($file,'file.size'))
			{
				if (! $this->send->seek($file['offs'])) {
					Log::error(sprintf('%s: ! Cannot send file from requested offset [%d]',self::LOGKEY,$file['offs']));

					$this->msgs(self::BPM_ERR,'Can\'t send file from requested offset');
					$this->send->close(FALSE);
					$this->sessionClear(self::SE_SENDFILE);

				} else {
					$this->sessionClear(self::SE_WAITGET);
					Log::debug(sprintf('%s:Sending packet [%s] as [%s]',self::LOGKEY,$this->send->name,$this->send->sendas));
					$this->msgs(self::BPM_FILE,sprintf('%s %lu %ld %lu',$this->send->sendas,$this->send->size,$this->send->mtime,$file['offs']));
				}

			} else {
				Log::error(sprintf('%s: ! M_got for unknown file [%s]',self::LOGKEY,$buf));
			}

		} else {
			Log::error(sprintf('%s: - UNPARSABLE file info [%s]',self::LOGKEY,$buf));
		}

		return 1;
	}

	/**
	 * M_GOT/M_SKIP commands
	 *
	 * @param string $buf
	 * @return int
	 * @throws Exception
	 */
	private function M_gotskip(string $buf):int
	{
		Log::debug(sprintf('%s:+ M_gotskip [%s]',self::LOGKEY,$buf));

		if ($file = $this->file_parse($buf)) {
			if ($this->send->sendas
				&& ! strncasecmp(Arr::get($file,'file.name'),$this->send->sendas,self::MAX_PATH)
				&& $this->send->mtime == Arr::get($file,'file.mtime')
				&& $this->send->size == Arr::get($file,'file.size'))
			{
				// @todo Commit our mail transaction if the remote end confirmed receipt of the file.
				if ($this->sessionGet(self::SE_SENDFILE)) {
					Log::debug(sprintf('%s:Packet [%s] sent. (%s)',self::LOGKEY,$this->send->sendas,$this->send->name));
					$this->sessionClear(self::SE_SENDFILE);
					$this->send->close(TRUE);

					return 1;
				}

				if ($this->sessionGet(self::SE_WAITGOT)) {
					Log::debug(sprintf('%s:Packet [%s] sent. (%s)',self::LOGKEY,$this->send->sendas,$this->send->name));
					$this->sessionClear(self::SE_WAITGOT);
					$this->send->close(TRUE);

				} else {
					Log::error(sprintf('%s: ! M_got[skip] for unknown file [%s]',self::LOGKEY,$buf));
				}
			}

		} else {
			Log::error(sprintf('%s: - UNPARSABLE file info [%s]',self::LOGKEY,$buf));
		}

		return 1;
	}

	/**
	 * @throws Exception
	 */
	private function M_nul(string $buf): int
	{
		Log::debug(sprintf('%s:+ M_nul [%s]',self::LOGKEY,$buf));

		if (! strncmp($buf,'SYS ',4)) {
			$this->node->system = $this->skip_blanks(substr($buf,4));

		} elseif (! strncmp($buf, 'ZYZ ',4)) {
			$this->node->sysop = $this->skip_blanks(substr($buf,4));

		} elseif (! strncmp($buf,'LOC ',4)) {
			$this->node->location = $this->skip_blanks(substr($buf,4));

		} elseif (! strncmp($buf,'NDL ',4)) {
			$data = $this->skip_blanks(substr($buf,4));
			$comma = strpos($data,',');
			$spd = substr($data,0,$comma);

			if ($comma)
				$this->node->flags = substr($data,$comma+1);

			if ($spd >= 300) {
				$this->client->speed = $spd;

			} else {
				$comma = $this->skip_blanks(substr($buf,4));
				$c = 0;
				while (($x=substr($comma,$c,1)) && is_numeric($x))
					$c++;

				$comma = substr($comma,0,$c);

				if (! $comma) {
					$this->client->speed = SocketClient::TCP_SPEED;

				} elseif (strtolower(substr($comma,$c+1,1)) == 'k') {
					$this->client->speed = $spd * 1024;

				} elseif (strtolower(substr($comma,$c+1,1)) == 'm') {
					$this->client->speed = $spd * 1024 * 1024;

				} else {
					$this->client->speed = SocketClient::TCP_SPEED;
				}
			}

		} elseif (! strncmp($buf,'TIME ',5)) {
			$this->node->node_time = $this->skip_blanks(substr($buf,5));

		} elseif (! strncmp($buf,'VER ',4)) {
			$data = $this->skip_blanks(substr($buf,4));
			$matches = [];
			preg_match('#^(.+)\s+binkp/([0-9]+)\.([0-9]+)$#',$data,$matches);

			$this->node->software = $matches[1];
			$this->node->ver_major = $matches[2];
			$this->node->ver_minor = $matches[3];

		} elseif (! strncmp($buf,'TRF ',4)) {
			$data = $this->skip_blanks(substr($buf,4));
			$matches = [];
			preg_match('/^([0-9]+)\s+([0-9]+)$/',$data,$matches);

			$this->node->netmail = isset($matches[1]) ?: 0;
			$this->node->files = isset($matches[2]) ?: 0;

			if ($this->node->netmail + $this->node->files)
				$this->sessionSet(self::SE_DELAYEOB);

		} elseif (! strncmp($buf,'FREQ',4)) {
			$this->sessionSet(self::SE_DELAYEOB);

		} elseif (! strncmp($buf,'PHN ',4)) {
			$this->node->phone = $this->skip_blanks(substr($buf,4));

		} elseif (! strncmp($buf,'OPM ',4)) {
			$this->node->message = $this->skip_blanks(substr($buf,4));

		} elseif (! strncmp($buf,'OPT ',4)) {
			$data = $this->skip_blanks(substr($buf,4));

			while ($data && ($p = $this->strsep($data,' '))) {
				if (! strcmp($p,'NR')) {
					$this->setup->opt_nr |= self::O_WE;

				} elseif (! strcmp($p,'MB')) {
					$this->setup->opt_mb |= self::O_WE;

				} elseif (! strcmp($p,'ND')) {
					$this->setup->opt_nd |= self::O_WE;

				} elseif (! strcmp($p,'NDA')) {
					$this->setup->opt_nd |= self::O_EXT;

				} elseif (! strcmp($p,'CHAT')) {
					$this->setup->opt_cht |= self::O_WE;

				} elseif (! strcmp($p,'CRYPT')) {
					$this->setup->opt_cr |= self::O_THEY;

				} elseif (! strncmp($p,'CRAM-MD5-',9) && $this->originate && $this->setup->opt_md) {
					if (($x=strlen(substr($p,9))) > 64 ) {
						Log::error(sprintf('%s: - M_nul Got TOO LONG [%d] challenge string',self::LOGKEY,$x));

					} else {
						$this->md_challenge = hex2bin(substr($p,9));

						if ($this->md_challenge)
							$this->setup->opt_md |= self::O_THEY;
					}

					if (($this->setup->opt_md&(self::O_THEY|self::O_WANT)) == (self::O_THEY|self::O_WANT))
						$this->setup->opt_md = self::O_YES;

				} else { /* if ( strcmp( p, "GZ" ) || strcmp( p, "BZ2" ) || strcmp( p, "EXTCMD" )) */
					Log::warning(sprintf('%s: - M_nul Got UNSUPPORTED option [%s]',self::LOGKEY,$p));
				}

				$data = substr($data,strpos($data,' '));
			}

		} else {
			Log::warning(sprintf('%s: - M_nul Got UNKNOWN NUL [%s]',self::LOGKEY,$buf));
		}

		return 1;
	}

	/**
	 * Remote accepted our password
	 *
	 * @throws Exception
	 */
	private function M_ok(string $buf): int
	{
		Log::debug(sprintf('%s:+ M_ok [%s]',self::LOGKEY,$buf));

		if (! $this->originate) {
			Log::error(sprintf('%s: ! UNEXPECTED M_OK [%s] from remote on incoming call',self::LOGKEY,$buf));

			$this->rc = self::S_FAILURE;
			return 0;
		}

		$buf = $this->skip_blanks($buf);

		if ($this->optionGet(self::O_PWD) && $buf) {
			while (($t = $this->strsep($buf," \t")))
				if (strcmp($t,'non-secure') == 0) {
					Log::debug(sprintf('%s: - Non Secure',self::LOGKEY));

					$this->setup->opt_cr = self::O_NO;
					$this->optionClear(self::O_PWD);

					break;
				}
		}

		// Add our mail to the queue if we have authenticated
		if ($this->node->aka_authed)
			foreach ($this->node->aka_remote_authed as $ao) {
				$this->send->mail($ao);
			}

		$this->msgs(self::BPM_NUL,sprintf('TRF %lu %lu',$this->send->mail_size,$this->send->file_size));

		Log::debug(sprintf('%s:= M_ok',self::LOGKEY));
		return $this->binkp_hsdone();
	}

	/**
	 * @throws Exception
	 */
	private function M_pwd(string $buf): int
	{
		Log::debug(sprintf('%s:+ M_pwd [%s]',self::LOGKEY,$buf));

		$buf = $this->skip_blanks($buf);
		$have_CRAM = !strncasecmp($buf,'CRAM-MD5-',9);
		$have_pwd = $this->optionGet(self::O_PWD);

		if ($this->originate) {
			Log::error(sprintf('%s: ! Unexpected password [%s] from remote on OUTGOING call',self::LOGKEY,$buf));

			$this->rc = self::S_FAILURE;
			return 0;
		}

		if ($this->md_challenge) {
			if ($have_CRAM) {
				// Loop to match passwords
				$this->node->auth(substr($buf,9),$this->md_challenge);
				$this->setup->opt_md |= self::O_THEY;

			} elseif ($this->setup->opt_md&self::O_NEED) {
				Log::error(sprintf('%s: ! Remote doesnt support MD5',self::LOGKEY));

				$this->msgs( self::BPM_ERR,'You must support MD5');
				$this->rc = self::S_FAILURE;

				return 0;
			}
		}

		if (! $this->md_challenge || (! $have_CRAM && ! ($this->setup->opt_md&self::O_NEED))) {
			// Loop to match passwords
			$this->node->auth($buf);
		}

		if ($have_pwd) {
			// If no passwords matched (ie: aka_authed is 0)
			if (! $this->node->aka_authed) {
				Log::error(sprintf('%s: ! Bad password [%s]',self::LOGKEY,$buf));

				$this->msgs(self::BPM_ERR,'Security violation');
				$this->optionSet(self::O_BAD);
				$this->rc = self::S_FAILURE;

				return 0;
			}

		} elseif (! $this->node->aka_authed) {
			Log::notice(sprintf('%s: - Remote proposed password for us [%s]',self::LOGKEY,$buf));
		}

		if (($this->setup->opt_md&(self::O_THEY|self::O_WANT )) == (self::O_THEY|self::O_WANT))
			$this->setup->opt_md = self::O_YES;

		if (!$have_pwd || $this->setup->opt_md != self::O_YES)
			$this->setup->opt_cr = self::O_NO;

		$tmp = sprintf('%s%s%s%s%s%s',
			($this->setup->opt_nr&self::O_WANT)		? ' NR' : '',
			($this->setup->opt_nd&self::O_THEY)				? ' ND' : '',
			($this->setup->opt_mb&self::O_WANT)				? ' MB' : '',
			($this->setup->opt_cht&self::O_WANT)			? ' CHAT' : '',
			(! ($this->setup->opt_nd&self::O_WE) != (! ($this->setup->opt_nd&self::O_THEY))) ? ' NDA': '',
			(($this->setup->opt_cr&self::O_WE) && ($this->setup->opt_cr&self::O_THEY )) ? ' CRYPT' : '');

		if (strlen($tmp))
			$this->msgs(self::BPM_NUL,sprintf('OPT%s',$tmp));

		// Add our mail to the queue if we have authenticated
		if ($this->node->aka_authed)
			foreach ($this->node->aka_remote_authed as $ao) {
				$this->send->mail($ao);
			}

		$this->msgs(self::BPM_NUL,sprintf('TRF %lu %lu',$this->send->mail_size,$this->send->file_size));
		$this->msgs(self::BPM_OK,sprintf('%ssecure',$have_pwd ? '' : 'non-'));

		return $this->binkp_hsdone();
	}

	protected function protocol_init(): int
	{
		// Not Used
		return 0;
	}

	/**
	 * Setup our BINKP session
	 *
	 * @return int
	 * @throws Exception
	 */
	protected function protocol_session(): int
	{
		Log::debug(sprintf('%s:+ protocol_session',self::LOGKEY));

		if ($this->binkp_init() != self::OK)
			return $this->originate ? (self::S_REDIAL|self::S_ADDTRY) : self::S_FAILURE;

		$this->binkp_hs();

		while (TRUE) {
			if (! $this->sessionGet(self::SE_INIT|self::SE_SENDFILE|self::SE_SENTEOB|self::SE_NOFILES) && ! $this->send->fd) {
				// Open our next file to send
				if ($this->send->total_count && ! $this->send->fd)
					$this->send->open();

				if ($this->send->fd) {
					$this->sessionSet(self::SE_SENDFILE);

					if ($this->setup->opt_nr&self::O_WE) {
						$this->sessionSet(self::SE_WAITGET);

						Log::debug(sprintf('%s: - Waiting for M_GET',self::LOGKEY));
					}

					$this->msgs(self::BPM_FILE,
						sprintf('%s %lu %lu %ld',$this->send->sendas,$this->send->size,$this->send->mtime,$this->sessionGet(self::SE_WAITGET) ? -1 : 0));

					$this->sessionClear(self::SE_SENTEOB);

				} else {
					$this->sessionSet(self::SE_NOFILES);
				}
			}

			if (! $this->sessionGet(self::SE_INIT|self::SE_WAITGOT|self::SE_SENTEOB|self::SE_DELAYEOB) && $this->sessionGet(self::SE_NOFILES)) {
				$this->msgs(self::BPM_EOB,'');
				$this->sessionSet(self::SE_SENTEOB);
			}

			$this->rc = self::S_OK;

			if ($this->sessionGet(self::SE_SENTEOB) && $this->sessionGet(self::SE_RECVEOB)) {
				if ($this->mib < 3 || $this->node->get_versionint() <= 100) {
					break;
				}

				$this->mib = 0;
				$this->sessionClear(self::SE_RECVEOB|self::SE_SENTEOB);
			}

			$wd = ($this->mqueue->count() || $this->tx_left || ($this->sessionGet(self::SE_SENDFILE) && $this->send->fd && ! $this->sessionGet(self::SE_WAITGET)));
			$rd = TRUE;

			try {
				// @todo we need to catch a timeout if there are no reads/writes
				$rc = $this->client->ttySelect($rd,$wd,self::BP_TIMEOUT);

			} catch (Exception) {
				$this->error_close();
				$this->error = -2;

				break;
			}

			$this->rc = ($this->originate ? (self::S_REDIAL|self::S_ADDTRY) : self::S_BUSY);

			if ($rc == 0) {
				$this->error_close();
				$this->error = -1;

				break;
			}

			if ($rd && ! $this->binkp_recv())
				break;

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

			if (($this->mqueue->count() || $wd) && ! $this->binkp_send() && (! $this->send->total_count))
				break;
		}

		if ($this->error == -1)
			Log::error(sprintf('%s: ! protocol_session TIMEOUT',self::LOGKEY));

		elseif ($this->error > 0)
			Log::error(sprintf('%s: ! protocol_session Got ERROR [%d]',self::LOGKEY,$this->socket_error));

		while (! $this->error) {
			try {
				$buf = $this->client->read(0,self::MAX_BLKSIZE);

			} catch (Exception $e) {
				if ($e->getCode() !== 11) {
					Log::debug(sprintf('%s: ? protocol_session Got Exception [%d] (%s)',self::LOGKEY,$e->getCode(),$e->getMessage()));

					$this->error = 1;
				}

				break;
			}

			if (strlen($buf) == 0)
				break;

			Log::warning(sprintf('%s: - Purged (%s) [%d] bytes from input stream',self::LOGKEY,hex_dump($buf),strlen($buf)));
		}

		while (! $this->error && ($this->mqueue->count() || $this->tx_left) && $this->binkp_send());

		return $this->rc;
	}

	/**
	 * Strip blanks at the beginning of a string
	 *
	 * @param string $str
	 * @return string
	 * @throws Exception
	 */
	private function skip_blanks(string $str): string
	{
		$c = 0;

		if ($str != NULL)
			while ($this->isSpace(substr($str,$c,1)))
				$c++;

	    return substr($str,$c);
	}

	/**
	 * Return the string delimited by char and shorten the input to the remaining characters
	 *
	 * @param string $str
	 * @param string $char
	 * @return string
	 */
	private function strsep(string &$str,string $char): string
	{
		$return = strstr($str,$char,TRUE) ?: $str;
		$str = substr($str,strlen($return)+strlen($char));

		return $return;
	}

	/**
	 * Check if the string is a space
	 *
	 * @param string $str
	 * @return bool
	 * @throws Exception
	 */
	private function isSpace(string $str):bool
	{
		if (strlen($str) > 1)
			throw new Exception('String is more than 1 char');

		return $str && in_array($str,[' ',"\n","\r","\v","\f","\t"]);
	}
}