<?php

namespace App\Classes\FTN;

use Carbon\Carbon;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\File\File;

use App\Classes\FTN as FTNBase;
use App\Models\{Address,Software,System,Zone};

/**
 * Represents the structure of a message bundle
 */
class Packet extends FTNBase implements \Iterator, \Countable
{
	private const LOGKEY = 'PKT';

	private const BLOCKSIZE			= 1024;
	protected const PACKED_MSG_LEAD	= "\02\00";

	public const PACKET_TYPES 		= [
		'2.2'	=> FTNBase\Packet\FSC45::class,
		'2+'	=> FTNBase\Packet\FSC48::class,
		'2e'	=> FTNBase\Packet\FSC39::class,
		'2.0'	=> FTNBase\Packet\FTS1::class,
	];

	protected array $header;			// Packet Header
	protected ?string $name;			// Packet name

	public File $file;					// Packet filename
	public Collection $messages;		// Messages in the Packet
	public Collection $errors;			// Messages that fail validation
	public bool $use_cache = FALSE;		// Use a cache for messages.
	protected int $index;					// Our array index

	/**
	 * @param string|null $header
	 * @throws \Exception
	 */
	public function __construct(string $header=NULL)
	{
		$this->messages = collect();
		$this->errors = collect();
		$this->domain = NULL;
		$this->name = NULL;

		if ($header)
			$this->header = unpack(self::unpackheader(static::HEADER),$header);
	}

	/**
	 * @throws \Exception
	 */
	public function __get($key)
	{
		switch ($key) {
			// From Addresses
			case 'fz': return Arr::get($this->header,'ozone');
			case 'fn': return Arr::get($this->header,'onet');
			case 'ff': return Arr::get($this->header,'onode');
			case 'fp': return Arr::get($this->header,'opoint');
			case 'fd': return rtrim(Arr::get($this->header,'odomain',"\x00"));

			// To Addresses
			case 'tz': return Arr::get($this->header,'dzone');
			case 'tn': return Arr::get($this->header,'dnet');
			case 'tf': return Arr::get($this->header,'dnode');
			case 'tp': return Arr::get($this->header,'dpoint');
			case 'td': return rtrim(Arr::get($this->header,'ddomain',"\x00"));

			case 'date':
				return Carbon::create(
					Arr::get($this->header,'y'),
					Arr::get($this->header,'m')+1,
					Arr::get($this->header,'d'),
					Arr::get($this->header,'H'),
					Arr::get($this->header,'M'),
					Arr::get($this->header,'S')
				);

			case 'password':
				return rtrim(Arr::get($this->header,$key),"\x00");

			case 'fftn':
			case 'fftn_o':
			case 'tftn':
			case 'tftn_o':
				return parent::__get($key);

			case 'software':
				$code = Arr::get($this->header,'prodcode-hi')<<8|Arr::get($this->header,'prodcode-lo');
				Software::unguard();
				$o = Software::singleOrNew(['code'=>$code,'type'=>Software::SOFTWARE_TOSSER]);
				Software::reguard();

				return $o;

			case 'software_ver':
				return sprintf('%d.%d',Arr::get($this->header,'prodrev-maj'),Arr::get($this->header,'prodrev-min'));

			case 'capability':
				// This needs to be defined in child classes, since not all children have it
				return NULL;

			// Packet Type
			case 'type':
				return static::TYPE;

			// Packet name:
			case 'name':
				return $this->{$key} ?: sprintf('%08x',timew());

			default:
				throw new \Exception('Unknown key: '.$key);
		}
	}

	/**
	 * Return the packet
	 *
	 * @return string
	 * @throws \Exception
	 */
	public function __toString(): string
	{
		$return = $this->header();

		foreach ($this->messages as $o) {
			if ($o->packed)
				$return .= self::PACKED_MSG_LEAD.$o;
		}

		$return .= "\00\00";

		return $return;
	}

	/* STATIC */

	/**
	 * Site of the packet header
	 *
	 * @return int
	 */
	public static function header_len(): int
	{
		return collect(static::HEADER)->sum(function($item) { return Arr::get($item,2); });
	}

	/**
	 * This function is intended to be implemented in child classes to test if the packet
	 * is defined by the child object
	 *
	 * @see self::PACKET_TYPES
	 * @param string $header
	 * @return bool
	 */
	public static function is_type(string $header): bool
	{
		return FALSE;
	}

	/**
	 * Process a packet file
	 *
	 * @param mixed $f
	 * @param string $name
	 * @param int $size
	 * @param System|null $system
	 * @param bool $use_cache
	 * @return Packet
	 * @throws InvalidPacketException
	 */
	public static function process(mixed $f,string $name,int $size,System $system=NULL,bool $use_cache=FALSE): self
	{
		Log::debug(sprintf('%s:+ Opening Packet [%s] with size [%d]',self::LOGKEY,$name,$size));

		$o = FALSE;
		$header = '';
		$read_ptr = 0;

		// Determine the type of packet
		foreach (self::PACKET_TYPES as $type) {
			$header_len = $type::header_len();

			// PKT Header
			if ($read_ptr < $header_len) {
				$header .= fread($f,$header_len-$read_ptr);
				$read_ptr = ftell($f);
			}

			// Could not read header
			if (strlen($header) !== $header_len)
				throw new InvalidPacketException(sprintf('Length of header [%d] too short',strlen($header)));

			if ($type::is_type($header)) {
				$o = new $type($header);
				break;
			}
		}

		if (! $o)
			throw new InvalidPacketException('Cannot determine type of packet.');

		$o->use_cache = $use_cache;
		$o->name = $name;

		$x = fread($f,2);

		// End of Packet?
		if ((strlen($x) === 2) && ($x === "\00\00"))
			return $o;

		// Messages start with self::PACKED_MSG_LEAD
		if ((strlen($x) === 2) && ($x !== self::PACKED_MSG_LEAD))
			throw new InvalidPacketException('Not a valid packet: '.bin2hex($x));

		// No message attached
		else if (! strlen($x))
			throw new InvalidPacketException('No message in packet: '.bin2hex($x));

		$o->zone = $system?->zones->firstWhere('zone_id',$o->fz);

		// If zone is null, we'll take the zone from the packet
		if (! $o->zone)
			$o->zone = Zone::where('zone_id',$o->fz)->where('default',TRUE)->single();

		$buf_ptr = 0;
		$message = '';
		$readbuf = '';
		$last = '';

		while ($buf_ptr || (! feof($f) && ($readbuf=fread($f,self::BLOCKSIZE)))) {
			if (! $buf_ptr)
				$read_ptr = ftell($f);

			// Packed messages are Message::HEADER_LEN, prefixed with self::PACKED_MSG_LEAD
			if (strlen($message) < (Message::HEADER_LEN+strlen(self::PACKED_MSG_LEAD))) {
				$addchars = (Message::HEADER_LEN+strlen(self::PACKED_MSG_LEAD))-strlen($message);
				$message .= substr($readbuf,$buf_ptr,$addchars);
				$buf_ptr += $addchars;

				// If our buffer wasnt big enough...
				if ($buf_ptr >= strlen($readbuf)) {
					$buf_ptr = 0;
					continue;
				}
			}

			// Take 2 chars from the buffer and check if we have our end packet signature
			if ($last && ($buf_ptr === 0)) {
				$last .= substr($readbuf,0,2);

				if (($end=strpos($last,"\x00".self::PACKED_MSG_LEAD,$buf_ptr)) !== FALSE) {
					$o->parseMessage(substr($message,0,$end-2));
					$last = '';
					$message = '';
					$buf_ptr = 1+$end;

					// Loop to rebuild our header for the next message
					continue;
				}

				$last = '';
			}

			if (($end=strpos($readbuf,"\x00".self::PACKED_MSG_LEAD,$buf_ptr)) === FALSE) {
				// Just in case our packet break is at the end of the buffer
				$last = substr($readbuf,-2);

				if ((str_contains($last,"\x00")) && ($size-$read_ptr > 2)) {
					$message .= substr($readbuf,$buf_ptr);
					$buf_ptr = 0;

					continue;
				}

				$last = '';
				$end = strpos($readbuf,"\x00\x00\x00",$buf_ptr);
			}

			// See if we have found the end of the packet, if not read more.
			if ($end === FALSE && ($read_ptr < $size)) {
				$message .= substr($readbuf,$buf_ptr);
				$buf_ptr = 0;

				continue;

			} else {
				$message .= substr($readbuf,$buf_ptr,$end-$buf_ptr);
				$buf_ptr = $end+3;

				if ($buf_ptr >= strlen($readbuf))
					$buf_ptr = 0;
			}

			// Look for the next message
			$o->parseMessage($message);
			$message = '';
		}

		// If our message is still set, then we have an unprocessed message
		if ($message)
			$o->parseMessage($message);

		return $o;
	}

	/**
	 * Location of the version
	 *
	 * @return int
	 */
	public static function version_offset(): int
	{
		return Arr::get(collect(static::HEADER)->get('type'),0);
	}

	public static function version_offset_len(): int
	{
		return Arr::get(collect(static::HEADER)->get('type'),2);
	}

	/* INTERFACE */

	/**
	 * Number of messages in this packet
	 */
	public function count(): int
	{
		return $this->messages->count();
	}

	public function current(): Message
	{
		return $this->use_cache ? unserialize(Cache::pull($this->key())) : $this->messages->get($this->index);
	}

	public function key(): mixed
	{
		return $this->use_cache ? $this->messages->get($this->index) : $this->index;
	}

	public function next(): void
	{
		$this->index++;
	}

	public function rewind(): void
	{
		$this->index = 0;
	}

	public function valid(): bool
	{
		return (! is_null($this->key())) && ($this->use_cache ? Cache::has($this->key()) : $this->messages->has($this->key()));
	}

	/* METHODS */

	/**
	 * When creating a new packet, set the header.
	 *
	 * @param Address $oo
	 * @param Address $o
	 * @param string|null $passwd Override the password used in the packet
	 */
	public function addressHeader(Address $oo,Address $o,string $passwd=NULL): void
	{
		Log::debug(sprintf('%s:+ Creating packet for [%s]',self::LOGKEY,$o->ftn));

		$date = Carbon::now();

		// Create Header
		$this->header = [
			'ozone'			=> $oo->zone->zone_id,					// Orig Zone
			'dzone'			=> $o->zone->zone_id,					// Dest Zone
			'onet'			=> $oo->host_id ?: $oo->region_id,		// Orig Net
			'dnet'			=> $o->host_id ?: $o->region_id,		// Dest Net
			'onode'			=> $oo->node_id,						// Orig Node
			'dnode'			=> $o->node_id,							// Dest Node
			'opoint'		=> $oo->point_id,						// Orig Point
			'dpoint'		=> $o->point_id,						// Dest Point
			'odomain'		=> $oo->zone->domain->name,				// Orig Domain
			'ddomain'		=> $o->zone->domain->name,				// Dest Domain
			'y'				=> $date->format('Y'),			// Year
			'm'				=> $date->format('m')-1,			// Month
			'd'				=> $date->format('d'),			// Day
			'H'				=> $date->format('H'),			// Hour
			'M'				=> $date->format('i'),			// Minute
			'S'				=> $date->format('s'),			// Second
			'password'		=> (! is_null($passwd)) ? $passwd : $o->session('pktpass'),			// Packet Password
		];
	}

	/**
	 * Add a message to this packet
	 *
	 * @param Message $o
	 */
	public function addMail(Message $o): void
	{
		$this->messages->push($o);
	}

	/**
	 * Parse a message in a mail packet
	 *
	 * @param string $message
	 * @throws InvalidPacketException|\Exception
	 */
	private function parseMessage(string $message): void
	{
		Log::info(sprintf('%s:Processing message [%d] bytes',self::LOGKEY,strlen($message)));

		$msg = Message::parseMessage($message,$this->zone);

		// If the message is invalid, we'll ignore it
		if ($msg->errors) {
			Log::info(sprintf('%s:- Message [%s] has errors',self::LOGKEY,$msg->msgid));

			// If the from address doenst exist, we'll create a new entry
			if ($msg->errors->messages()->has('to') && $msg->tzone) {
				try {
					// @todo Need to work out the correct region for the host_id
					Address::unguard();
					$ao = Address::firstOrNew([
						'zone_id' => $msg->tzone->id,
						//'region_id' => 0,
						'host_id' => $msg->tn,
						'node_id' => $msg->tf,
						'point_id' => $msg->tp,
						'active' => TRUE,
					]);
					Address::reguard();

					if (is_null($ao->region_id))
						$ao->region_id = $ao->host_id;

				} catch (\Exception $e) {
					Log::error(sprintf('%s:! Error finding/creating TO address [%s] for message',self::LOGKEY,$msg->tboss),['error'=>$e->getMessage()]);
					$this->errors->push($msg);
					return;
				}

				$ao->role = Address::NODE_UNKNOWN;

				System::unguard();
				$so = System::firstOrCreate([
					'name' => 'Discovered System',
					'sysop' => 'Unknown',
					'location' => '',
					'active' => TRUE,
				]);
				System::reguard();

				$so->addresses()->save($ao);

				Log::alert(sprintf('%s: - To FTN is not defined, creating new entry for [%s] (%d)',self::LOGKEY,$msg->tboss,$ao->id));
			}

			if ($msg->errors->messages()->has('from') && $msg->tzone) {
				try {
					// @todo Need to work out the correct region for the host_id
					Address::unguard();
					$ao = Address::firstOrNew([
						'zone_id' => $msg->fzone->id,
						//'region_id' => 0,
						'host_id' => $msg->fn,
						'node_id' => $msg->ff,
						'point_id' => $msg->fp,
						'active'=> TRUE,
					]);
					Address::reguard();

					if (is_null($ao->region_id))
						$ao->region_id = $ao->host_id;

				} catch (\Exception $e) {
					Log::error(sprintf('%s:! Error finding/creating FROM address [%s] for message',self::LOGKEY,$msg->fboss),['error'=>$e->getMessage()]);
					$this->errors->push($msg);
					return;
				}

				$ao->role = Address::NODE_UNKNOWN;

				System::unguard();
				$so = System::firstOrCreate([
					'name' => 'Discovered System',
					'sysop' => 'Unknown',
					'location' => '',
					'active' => TRUE,
				]);
				System::reguard();

				$so->addresses()->save($ao);

				Log::alert(sprintf('%s: - From FTN is not defined, creating new entry for [%s] (%d)',self::LOGKEY,$msg->fboss,$ao->id));
			}

			if ($msg->errors->messages()->has('user_from') || $msg->errors->messages()->has('user_to')) {
				Log::error(sprintf('%s:! Skipping message [%s] due to errors (%s)...',self::LOGKEY,$msg->msgid,join(',',$msg->errors->messages()->keys())));
				$this->errors->push($msg);
				return;
			}
		}

		if ($this->use_cache) {
			$key = urlencode($msg->msgid ?: sprintf('%s %s',$msg->fftn,Carbon::now()->timestamp));
			if (! Cache::forever($key,serialize($msg)))
				throw new \Exception(sprintf('Caching failed for key [%s]?',$key));

			$this->messages->push($key);

		} else {
			$this->messages->push($msg);
		}
	}
}