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

final class Binkp extends BaseProtocol
{
	/* -- */
	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;
	private const O_WANT		= 1;
	private const O_WE			= 2;
	private const O_THEY		= 4;
	private const O_NEED		= 8;
	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;
	private int $rx_size;

	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)) {
			$this->session(self::SESSION_BINKP,$client,(new Address));
			$this->client->close();
			Log::info(sprintf('%s: = End - Connection closed [%s]',__METHOD__,$client->getAddress()));
		}

		return NULL;
	}

	/**
	 * @throws Exception
	 */
	private function binkp_hs(): void
	{
		Log::debug(sprintf('%s: + Start',__METHOD__));

		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
		// @todo Implement hiding our AKAs not in this network.
		if ($this->originate)
			$this->msgs(self::BPM_ADR,join(' ',$this->setup->system->addresses->pluck('ftn')->toArray()));
	}

	/**
	 * @return int
	 */
	private function binkp_hsdone(): int
	{
		Log::debug(sprintf('%s: + Start',__METHOD__));

		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',__METHOD__));

		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',
			__METHOD__,
			$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
	{
		Log::debug(sprintf('%s: + Start',__METHOD__));

		$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:   ! Error - Unknown BINKP option [%s]',__METHOD__,$this->setup->binkp_options[$x]));
			}
		}

		return self::OK;
	}

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

		$buf = '';
		$blksz = $this->rx_size == -1 ? BinkpMessage::BLK_HDR_SIZE : $this->rx_size;

		if ($blksz !== 0) {
			try {
				$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:   - Socket EAGAIN',__METHOD__));

					return 1;
				}

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

				return 0;
			}

			if (strlen($buf) == 0) {
				// @todo Check that this is correct.
				Log::debug(sprintf('%s:   - Was the socket closed by the remote?',__METHOD__));
				$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($buf);

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

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

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

				$rc = 1;

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

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

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

						return 1;
					}

					if ($this->DEBUG)
						Log::debug(sprintf('%s:   - BUFFER [%d]',__METHOD__,strlen($buf)));

					$rc = ord(substr($buf,0,1));

					if ($rc > self::BPM_MAX) {
						Log::error(sprintf('%s:  ! Unknown Message [%s] (%d)',__METHOD__,$buf,strlen($buf)));
						$rc = 1;

					} else {
						//DEBUG(('B',2,"rcvd %s '%s'%s",mess[rc],bp->rx_buf + 1,CRYPT(bps)));	//@todo CRYPT
						$data = substr($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:   ! Command not implemented [%d]',__METHOD__,$rc));
								$rc = 1;
						}
					}

				} else {
					$tmp = sprintf('%s %lu %lu',$this->recv->name,$this->recv->size,$this->recv->mtime);	// @todo move to Receive?

					if ($this->recv->fd) {
						try {
							$rc = $this->recv->write($buf);

						} catch (Exception $e) {
							Log::error(sprintf('%s:   ! %s',__METHOD__,$e->getMessage()));
							$this->recv->close();
							$this->msgs(self::BPM_SKIP,$tmp);
							$rc = 1;
						}

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

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

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

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

		} else {
			$rc = 1;
		}

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

		return $rc;
	}

	/**
	 * @throws Exception
	 */
	private function binkp_send(): int
	{
		if ($this->DEBUG)
			Log::debug(sprintf('%s: + Start - tx_left [%d]',__METHOD__,$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:   - Msgs [%d]',__METHOD__,$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:   ! Unexpected ERROR [%s]',__METHOD__,$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: ! Error [%s]',__METHOD__,$e->getMessage()));
				return 0;
			}

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

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

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

	    return 1;
	}

	private function file_parse(string $str): ?array
	{
		Log::debug(sprintf('%s: + Start [%s]',__METHOD__,$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: + Start [%s]',__METHOD__,$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: + Start [%s]',__METHOD__,$buf));

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

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

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

					continue;
				}

			} catch (Exception) {
				Log::error(sprintf('%s:   ! AKA is INVALID [%s]',__METHOD__,$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]',__METHOD__,$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;

			// @todo Find files for node
			$this->send->add('/tmp/aa');

			Log::info(sprintf('%s:   = Node has [%lu] mail and [%lu] files - [%lu] items',__METHOD__,$this->send->mail_size,$this->send->file_size,$this->send->total_count));

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

		if ($rc == 0) {
			Log::error(sprintf('%s:   ! All AKAs [%d] are busy',__METHOD__,$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?',__METHOD__));

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

				return 0;
			}

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

			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.
		// @todo make this an option to hideAKAs or not
		if (! $this->originate)
			$this->msgs(self::BPM_ADR,join(' ',$this->setup->system->addresses->pluck('ftn')->toArray()));

    	return 1;
	}

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

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

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

		return 1;
	}

	/**
	 * @throws Exception
	 */
	private function M_eob(string $buf): int
	{
		Log::debug(sprintf('%s: + Start [%s]',__METHOD__,$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)) {
			// @todo See if we need to send anything else, based on what we just recevied
			if ($this->send->total_count)
				$this->sessionClear(self::SE_NOFILES);
		}

		return 1;
	}

	/**
	 * @throws Exception
	 */
	private function M_err(string $buf): int
	{
		Log::debug(sprintf('%s: + Start [%s]',__METHOD__,$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: + Start [%s]',__METHOD__,$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]',__METHOD__,$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($file['offs']<0);

			return 1;
		}

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

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

				case self::FOP_SUSPEND:
					Log::info(sprintf('%s:   - File Suspended',__METHOD__));
					$this->msgs(self::BPM_SKIP, sprintf('%s %lu %lu',$this->recv->name,$this->recv->size,$this->recv->mtime));

					break;

				case self::FOP_SKIP:
					Log::info(sprintf('%s:   - File Skipped',__METHOD__));
					$this->msgs(self::BPM_GOT,sprintf('%s %lu %lu',$this->recv->name,$this->recv->size,$this->recv->mtime));

					break;

				case self::FOP_OK:
					Log::debug(sprintf('%s:   - Getting file from [%d]',__METHOD__,$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] (%lu %lu %ld)',__METHOD__,
						$this->recv->name,$this->recv->size,$this->recv->mtime,$file['offs']));

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

					break;
			}

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

			$this->msgs(self::BPM_SKIP,sprintf('%s %lu %lu',$this->recv->name,$this->recv->size,$this->recv->mtime));
		}

		return 1;
	}

	/**
	 * @throws Exception
	 */
	private function M_get(string $buf): int
	{
		Log::debug(sprintf('%s: + Start [%s]',__METHOD__,$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]',__METHOD__,$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);
					$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]',__METHOD__,$buf));
			}

		} else {
			Log::error(sprintf('%s:   - UNPARSABLE file info [%s]',__METHOD__,$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: + Start [%s]',__METHOD__,$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'))
			{
				if ($this->sessionGet(self::SE_SENDFILE)) {
					$this->send->close(TRUE);
					$this->sessionClear(self::SE_SENDFILE);

					return 1;
				}

				if ($this->sessionGet(self::SE_WAITGOT)) {
					$this->sessionClear(self::SE_WAITGOT);
					$this->send->close(TRUE);

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

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

		return 1;
	}

	/**
	 * @throws Exception
	 */
	private function M_nul(string $buf): int
	{
		Log::debug(sprintf('%s: + Start [%s]',__METHOD__,$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:   - Got TOO LONG [%d] challenge string',__METHOD__,$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:   - Got UNSUPPORTED option [%s]',__METHOD__,$p));
				}

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

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

		return 1;
	}

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

		if (! $this->originate) {
			Log::error(sprintf('%s: ! UNEXPECTED M_OK [%s] from remote on incoming call',__METHOD__,$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',__METHOD__));

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

					break;
				}
		}

		Log::debug(sprintf('%s: = End',__METHOD__));
		return $this->binkp_hsdone();
	}

	/**
	 * @throws Exception
	 */
	private function M_pwd(string $buf): int
	{
		Log::debug(sprintf('%s: + Start [%s]',__METHOD__,$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',__METHOD__,$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',__METHOD__));

				$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]',__METHOD__,$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]',__METHOD__,$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));

		$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
	}

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

		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',__METHOD__));
					}

					$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->mqueue->count() || $wd) && ! $this->binkp_send())
				break;
		}

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

		elseif ($this->error > 0)
			Log::error(sprintf('%s:   ! Got ERROR [%d]',__METHOD__,$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:   ? Got Exception [%d] (%s)',__METHOD__,$e->getCode(),$e->getMessage()));

					$this->error = 1;
				}

				break;
			}

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

			Log::warning(sprintf('%s:   - Purged (%s) [%d] bytes from input stream',__METHOD__,serialize($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"]);
	}
}