<?php

namespace App\Classes\FTN;

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

use App\Classes\FTN as FTNBase;
use App\Exceptions\InvalidPacketException;
use App\Models\{Address,Domain,Echomail,Netmail,Software,System,Zone};
use App\Notifications\Netmails\{EchomailBadAddress,NetmailBadAddress};

/**
 * Represents a Fidonet Packet, that contains an array of messages.
 *
 * Thus this object is iterable as an array of Echomail::class or Netmail::class.
 */
abstract class Packet extends FTNBase implements \Iterator, \Countable
{
	private const LOGKEY = 'PKT';

	protected const PACKED_MSG_LEAD	= "\02\00";
	protected const PACKED_END		= "\00\00";

	public const MSG_TYPE2			= 1<<0;
	public const MSG_TYPE4			= 1<<2;

	// @todo Rename this regex to something more descriptive, ie: FILENAME_REGEX
	public const regex = '([[:xdigit:]]{4})(?:-(\d{4,10}))?-(.+)';

	/**
	 * Packet types we support, in specific order for auto-detection to work
	 *
	 * @var string[]
	 */
	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 = NULL;		// Packet name

	public File $file;					// Packet filename
	protected Address $fftn_p;			// Address the packet is from (when packing messages)
	protected Address $tftn_p;			// Address the packet is to (when packing messages)
	protected Collection $messages;		// Messages in the Packet
	protected string $content;			// Outgoing packet data
	public Collection $errors;			// Messages that fail validation
	protected int $index;				// Our array index
	protected $pass_p = NULL;			// Overwrite the packet password (when packing messages)

	/* ABSTRACT */

	/**
	 * 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
	 */
	abstract public static function is_type(string $header): bool;
	abstract protected function header(Collection $msgs): string;

	/* STATIC */

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

	/**
	 * Process a packet file
	 *
	 * @param mixed $f File handler returning packet data
	 * @param string $name
	 * @param int $size
	 * @param Domain|null $domain
	 * @return Packet
	 * @throws InvalidPacketException
	 */
	public static function process(mixed $f,string $name,int $size,Domain $domain=NULL): 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->name = $name;

		$x = fread($f,2);
		if (strlen($x) === 2) {
			// End of Packet?
			if ($x === "\00\00")
				return $o;

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

		// No message attached
		} else
			throw new InvalidPacketException('Not a valid packet, not EOP or SOM:'.bin2hex($x));

		Log::info(sprintf('%s:- Packet [%s] is a [%s] packet',self::LOGKEY,$o->name,get_class($o)));

		// Work out the packet zone
		if ($o->fz && ($o->fd || $domain)) {
			$o->zone = Zone::select('zones.*')
				->join('domains',['domains.id'=>'zones.domain_id'])
				->where('zone_id',$o->fz)
				->where('name',$o->fd ?: $domain->name)
				->single();
		}

		// If zone is not set, then we need to use a default zone - the messages may not be from this zone.
		if (empty($o->zone)) {
			Log::alert(sprintf('%s:! We couldnt work out the packet zone, so we have fallen back to the default for [%d]',self::LOGKEY,$o->fz));

			$o->zone = Zone::where('zone_id',$o->fz)
				->where('default',TRUE)
				->singleOrFail();
		}

		Log::info(sprintf('%s:- Packet Dated [%s] from [%s] to [%s]',self::LOGKEY,$o->date,$o->fftn_t,$o->tftn_t));

		$message = '';		// Current message we are building
		$msgbuf = '';
		$leader = Message::header_len()+strlen(self::PACKED_MSG_LEAD);

		// We loop through reading from the buffer, to find our end of message tag
		while ((! feof($f) && ($readbuf=fread($f,$leader)))) {
			$read_ptr = ftell($f);
			$msgbuf .= $readbuf;

			// See if we have our EOM/EOP marker
			if ((($end=strpos($msgbuf,"\x00".self::PACKED_MSG_LEAD,$leader)) !== FALSE)
				|| (($end=strpos($msgbuf,"\x00".self::PACKED_END,$leader)) !== FALSE))
			{
				// Parse our message
				$o->parseMessage(substr($msgbuf,0,$end));

				$msgbuf = substr($msgbuf,$end+3);
				continue;

				// If we have more to read
			} elseif ($read_ptr < $size) {
				continue;
			}

			// If we get here
			throw new InvalidPacketException(sprintf('Cannot determine END of message/packet: %s|%s',get_class($o),hex_dump($message)));;
		}

		if ($msgbuf)
			throw new InvalidPacketException(sprintf('Unprocessed data in packet: %s|%s',get_class($o),hex_dump($msgbuf)));

		return $o;
	}

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

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

	/**
	 * @throws \Exception
	 */
	public function __get($key)
	{
		//Log::debug(sprintf('%s:/ Requesting key for Packet::class [%s]',self::LOGKEY,$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_t':
			case 'fftn':
			case 'tftn_t':
			case 'tftn':
				return parent::__get($key);

			case 'product':
				return Arr::get($this->header,'prodcode-hi')<<8|Arr::get($this->header,'prodcode-lo');

			case 'software':
				Software::unguard();
				$o = Software::singleOrNew(['code'=>$this->product,'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());

			case 'messages':
				return $this->{$key};

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

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

	/* INTERFACE */

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

	public function current(): Echomail|Netmail
	{
		return $this->messages->get($this->index);
	}

	public function key(): mixed
	{
		return $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->messages->has($this->key());
	}

	/* METHODS */

	public function for(Address $ao): self
	{
		$this->tftn_p = $ao;
		$this->fftn_p = our_address($ao);

		return $this;
	}

	/**
	 * Generate a packet
	 *
	 * @return string
	 */
	public function generate(): string
	{
		return (string)$this;
	}

	public function mail(Collection $msgs): self
	{
		if (! $msgs->count())
			throw new InvalidPacketException('Refusing to make an empty packet');

		if (empty($this->tftn_p) || empty($this->fftn_p))
			throw new InvalidPacketException('Cannot generate a packet without a destination address');

		$this->content = $this->header($msgs);

		foreach ($msgs as $o)
			$this->content .= self::PACKED_MSG_LEAD.$o->packet($this->tftn_p);

		$this->content .= "\00\00";

		$this->messages = $msgs->map(fn($item)=>$item->only(['id','datetime']));

		return $this;
	}

	/**
	 * Parse a message in a mail packet
	 *
	 * @param string $message
	 * @throws InvalidPacketException|\Exception
	 */
	private function parseMessage(string $message): void
	{
		Log::info(sprintf('%s:+ Processing packet 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->count()) {
			Log::info(sprintf('%s:- Message [%s] has [%d] errors',self::LOGKEY,$msg->msgid ?: 'No ID',$msg->errors->count()));

			// If the messages is not for the right zone, we'll ignore it
			if ($msg->errors->has('invalid-zone')) {
				Log::alert(sprintf('%s:! Message [%s] is from an invalid zone [%s], packet is from [%s] - ignoring it',self::LOGKEY,$msg->msgid,$msg->set_fftn,$this->fz));

				// @todo $msg might not be echomail
				if (! $msg->kludges->get('RESCANNED'))
					Notification::route('netmail',$this->fftn)->notify(($msg instanceof Echomail) ? new EchomailBadAddress($msg) : new NetmailBadAddress($msg));

				return;
			}

			// If the $msg->fftn doesnt exist, we'll need to create it
			if ($msg->errors->has('from') && $this->fftn && $this->fftn->zone_id) {
				Log::debug(sprintf('%s:^ From address [%s] doesnt exist, it needs to be created',self::LOGKEY,$msg->set->get('set_fftn')));

				$ao = Address::findFTN($msg->set->get('set_fftn'),TRUE,TRUE);

				if ($ao?->exists && ($ao->zone?->domain_id !== $this->fftn->zone->domain_id)) {
					Log::alert(sprintf('%s:! From address [%s] domain [%d] doesnt match packet domain [%d]?',self::LOGKEY,$msg->set->get('set_fftn'),$ao->zone?->domain_id,$this->fftn->zone->domain_id));

					return;
				}

				if (! $ao) {
					$so = System::createUnknownSystem();
					$ao = Address::createFTN($msg->set->get('set_fftn'),$so);

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

				$msg->fftn_id = $ao->id;
				$msg->errors->forget('from');
				$msg->errors->forget('fftn_id');
			}

			// If the $msg->tftn doesnt exist, we'll need to create it
			if ($msg->errors->has('to') && $this->tftn && $this->tftn->zone_id) {
				Log::debug(sprintf('%s:^ To address [%s] doesnt exist, it needs to be created',self::LOGKEY,$msg->set->get('set_tftn')));

				$ao = Address::findFTN($msg->set->get('set_tftn'),TRUE,TRUE);

				if ($ao?->exists && ($ao->zone?->domain_id !== $this->tftn->zone->domain_id)) {
					Log::alert(sprintf('%s:! To address [%s] domain [%d] doesnt match packet domain [%d]?',self::LOGKEY,$msg->set->get('set_tftn'),$ao->zone?->domain_id,$this->fftn->zone->domain_id));

					return;
				}

				if (! $ao) {
					$so = System::createUnknownSystem();
					$ao = Address::createFTN($msg->set->get('set_fftn'),$so);

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

				$msg->tftn_id = $ao->id;
				$msg->errors->forget('to');
				$msg->errors->forget('tftn_id');
			}

			// If there is no fftn, then its from a system that we dont know about
			if (! $this->fftn) {
				Log::alert(sprintf('%s:! No further message processing, packet is from a system we dont know about [%s]',self::LOGKEY,$this->fftn_t));

				$this->messages->push($msg);
				return;
			}
		}

		// @todo If the message from domain (eg: $msg->fftn->zone->domain) is different to the packet address domain ($pkt->fftn->zone->domain), we'll skip this message
		Log::debug(sprintf('%s:^ Message [%s] - Packet from domain [%d], Message domain [%d]',self::LOGKEY,$msg->msgid,$this->fftn->zone->domain_id,$msg->fftn->zone->domain_id));

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

	/**
	 * Overwrite the packet password
	 *
	 * @param string|null $password
	 * @return self
	 */
	public function password(string $password=NULL): self
	{
		if ($password && (strlen($password) < 9))
			$this->pass_p = strtoupper($password);

		return $this;
	}
}