<?php

namespace App\Classes\Protocol;

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

use App\Classes\Protocol as BaseProtocol;
use App\Models\{Address,Domain,Mailer};

/**
 * Respond to DNS queries and provide addresses to FTN nodes.
 * http://ftsc.org/docs/fts-5004.001
 *
 * This implementation doesnt support EDNS nor DNSSEC.
 *
 * If using bind, the following configuration is required:
 * options {
 *  validate-except
 *  {
 *    "ftn";
 *  };
 * };
 *
 * and optionally
 * server <IP ADDRESS> {
 *  edns no;
 * };
 */
final class DNS extends BaseProtocol
{
	private const LOGKEY = 'PD-';

	/* CONSTS */

	public const PORT			= 53;

	private const DEFAULT_TTL = 86400;
	private const TLD = 'ftn';

	private BaseProtocol\DNS\Query $query;

	// DNS Response codes
	public const DNS_NOERROR = 0;		// No error
	public const DNS_FORMERR = 1;		// Format Error - The dns server could not read / understand the query
	public const DNS_SERVFAIL = 2;		// Server Failure - There was a dns error with the dns server
	public const DNS_NAMEERR = 3;		// Name Error - This specifies that the domain name in the query does not exist, it is only valid from an authoritative server
	public const DNS_NOTIMPLEMENTED = 4;// Not implemented - The requested query is not supported by the dns server
	public const DNS_REFUSED = 5;		// Refused - The dns server refuses to process the dns query

	// DNS Query Classes
	public const DNS_QUERY_IN = 1;		// Internet (this is the main one that is used)

	// DNS Query Types
	public const DNS_TYPE_A	= 1;		// A Records
	public const DNS_TYPE_NS = 2;		// NS Records
	public const DNS_TYPE_CNAME	= 5;	// CNAME Records
	public const DNS_TYPE_SOA = 6;		// SOA Records
	public const DNS_TYPE_MX = 15;		// MX Records
	public const DNS_TYPE_TXT = 16;		// TXT Records
	public const DNS_TYPE_AAAA = 28;	// AAAA Records
	public const DNS_TYPE_SRV = 33;		// SRV Records
	public const DNS_TYPE_OPT = 41;		// OPT Records
	public const DNS_TYPE_DS = 43;		// DS Records (Delegation signer RFC 4034)

	protected function protocol_init(): int
	{
		// N/A
		return 0;
	}

	/**
	 * Handle a DNS query
	 *
	 * https://www.ietf.org/rfc/rfc1035.txt
	 * https://www.ietf.org/rfc/rfc2308.txt
	 * https://github.com/guyinatuxedo/dns-fuzzer/blob/master/dns.md
	 *
	 * labels          63 octets or less
	 * names           255 octets or less
	 * TTL             positive values of a signed 32 bit number.
	 * UDP messages    512 octets or less
	 *
	 * @param bool $force_queue Not used here
	 * @return int
	 * @throws \Exception
	 */
	public function protocol_session(bool $force_queue=FALSE): int
	{
		Log::debug(sprintf('%s:+ DNS Query',self::LOGKEY));

		try {
			$this->query = new BaseProtocol\DNS\Query($this->client->read(0,512));

		} catch (\Exception $e) {
			Log::notice(sprintf('%s:! Ignoring bad DNS query (%s)',self::LOGKEY,$e->getMessage()));

			return FALSE;
		}

		Log::info(sprintf('%s:= DNS Query from [%s] for [%s]',self::LOGKEY,$this->client->address_remote,$this->query->domain));

		// If the wrong class
		if ($this->query->class !== self::DNS_QUERY_IN) {
			Log::notice(sprintf('%s:! We only service Internet queries [%d]',self::LOGKEY,$this->query->class));
			return $this->reply(self::DNS_NOTIMPLEMENTED,[],$this->soa());
		}

		$dos = Domain::select(['id','name','dnsdomain'])->active();
		$ourdomains = $dos
			->pluck('name')
			->transform(function($item) { return sprintf('%s.%s',$item,self::TLD); })
			->merge($dos->pluck('dnsdomain'))
			->merge([self::TLD])
			->filter()
			->sortBy(function($item) { return substr_count($item,'.'); })
			->reverse();

		$query_domain = $this->query->domain;

		// If the query is not for our domains, return NAMEERR
		if (($do=$ourdomains->search(function ($item) use ($query_domain) { return preg_match("/${item}$/",$query_domain); })) === FALSE) {
			Log::alert(sprintf('%s:= DNS Query not for our domains',self::LOGKEY));
			return $this->nameerr();
		}

		// Action on the query type
		switch ($this->query->type) {
			// Return the SOA/NS records
			case self::DNS_TYPE_SOA:
				Log::info(sprintf('%s:= Returning SOA for [%s]',self::LOGKEY,$this->query->domain));

				return $this->reply(
					self::DNS_NOERROR,
					$this->soa(),
					[],
					[serialize($this->domain_split(config('fido.dns_ns'))) => self::DNS_TYPE_NS],
				);

			case self::DNS_TYPE_NS:
				Log::info(sprintf('%s:= Returning NS for [%s]',self::LOGKEY,$this->query->domain));

				return $this->reply(
					self::DNS_NOERROR,
					[serialize($this->domain_split(config('fido.dns_ns'))) => self::DNS_TYPE_NS]);

			// Respond to A/AAAA/CNAME queries, with value or NAMEERR
			case self::DNS_TYPE_CNAME:
			case self::DNS_TYPE_A:
			case self::DNS_TYPE_AAAA:
			case self::DNS_TYPE_SRV:
			case self::DNS_TYPE_TXT:
				Log::debug(sprintf('%s:= Looking for record [%s] for [%s]',self::LOGKEY,$this->query->type,$this->query->domain));

				$labels = clone($this->query->labels);
				$mailer = '';

				// If this is a SRV record query
				if ($this->query->type === self::DNS_TYPE_SRV) {
					if ($labels->skip(1)->first() !== '_tcp')
						return $this->reply(self::DNS_NAMEERR);

					switch ($labels->first()) {
						case '_binkp':
							$mailer = Mailer::where('name','BINKP')->sole();
							break;

						case '_ifcico':
							$mailer = Mailer::where('name','EMSI')->sole();
							break;

						default:
							return $this->reply(self::DNS_NAMEERR);
					}

					$labels->shift(2);
				}

				// First check that it is a query we can answer
				// First label should be p.. or f..
				if (! is_null($p=$this->parse('p',$labels->first())))
					$labels->shift();
				else
					$p = 0;

				// We'll assume f0
				if (! is_null($f=$this->parse('f',$labels->first())))
					$labels->shift();
				else
					$f = 0;

				if (is_null($n=$this->parse('n',$labels->shift())))
					return $this->nameerr();

				if (is_null($z=$this->parse('z',$labels->shift())))
					return $this->nameerr();

				// If the query doesnt end with .ftn, then the remainder of the query is the domain name
				if (($labels->search(self::TLD) !== FALSE))
					$d = $labels->shift();
				else
					$d = '';

				// Make sure we have a root/base domain
				if (! $labels->count())
					return $this->nameerr();

				$rootdn = $labels->join('.');

				if (! $d && ($rootdn !== self::TLD))
					$d = Domain::where('dnsdomain',$rootdn)->single()?->name;

				$ao = Address::findFTN(sprintf('%d:%d/%d.%d@%s',$z,$n,$f,$p,$d));

				// Check we have the right record
				if ((! $ao) || (($rootdn !== self::TLD) && ((! $ao->zone->domain->dnsdomain) || ($ao->zone->domain->dnsdomain !== $rootdn)))) {
					Log::alert(sprintf('%s:= No DNS record for [%d:%d/%d.%d@%s]',self::LOGKEY,$z,$n,$f,$p,$d));

					return $this->nameerr();
				}

				switch ($this->query->type) {
					case self::DNS_TYPE_SRV:
						if (($ao->system->address) && ($xx=$ao->system->mailers->where('id',$mailer->id)->pop())) {
							Log::info(sprintf('%s:= Returning [%s] for DNS query [%s]',self::LOGKEY,$ao->system->address,$ao->ftn));

							return $this->reply(
								self::DNS_NOERROR,
								[serialize([
									0, // priority
									1, // weight
									$xx->pivot->port,
									$this->domain_split($ao->system->address),
								]) => self::DNS_TYPE_SRV]);

						} else {
							Log::alert(sprintf('%s:! No/incomplete hostname/port details for [%d] for DNS query [%s]',self::LOGKEY,$ao->system->id,$ao->ftn));

							return $this->nodata();
						}

					case self::DNS_TYPE_TXT:
						Log::info(sprintf('%s:= Returning [%s] for DNS query [%s]',self::LOGKEY,$ao->system->name,$ao->ftn));

						return $this->reply(
							self::DNS_NOERROR,
							[serialize($ao->system->name) => self::DNS_TYPE_TXT]);

					default:
						Log::info(sprintf('%s:= Returning [%s] for DNS query [%s]',self::LOGKEY,$ao->system->address ?: 'NO ADDRESS',$ao->ftn));

						return (! $ao->system->address)
							? $this->nodata()
							: $this->reply(
								self::DNS_NOERROR,
								[serialize($this->domain_split($ao->system->address)) => self::DNS_TYPE_CNAME]);
				}

			// Other attributes return NOTIMPL
			default:
				Log::notice(sprintf('%s:! We dont support DNS query types [%d]',self::LOGKEY,$this->query->type));

				return $this->reply(self::DNS_NOTIMPLEMENTED,[],$this->soa());
		}
	}

	/**
	 * Return a compression string for a specific offset
	 *
	 * @param int $offset
	 * @return string
	 */
	private function compress(int $offset): string
	{
		return pack('n',$offset | (3 << 14));
	}

	/**
	 * Split a domain into a DNS domain string
	 *
	 * @param string $domain
	 * @return string
	 */
	private function domain_split(string $domain): string
	{
		$a = '';

		foreach (explode('.',$domain) as $item)
			$a .= pack('C',strlen($item)).$item;

		$a .= "\x00";

		return $a;
	}

	private function nameerr(): int
	{
		Log::notice(sprintf('%s:! DNS query for a resource we dont manage [%s]',self::LOGKEY,$this->query->domain));

		return $this->reply(self::DNS_NAMEERR,[],$this->soa());
	}

	private function nodata(): int
	{
		Log::notice(sprintf('%s:! DNS query for a resource we dont manage [%s] in our zone(s)',self::LOGKEY,$this->query->domain));

		return $this->reply(self::DNS_NOERROR,[],$this->soa());
	}

	/**
	 * Parse a label for a fido address nibble
	 *
	 * @param string $prefix
	 * @param string $label
	 * @return string|null
	 */
	private function parse(string $prefix,string $label): ?string
	{
		$m = [];

		return (preg_match('/^'.$prefix.'([0-9]+)+/',$label,$m) && ($m[1] <= Address::ADDRESS_FIELD_MAX))
			? $m[1]
			: NULL;
	}

	/**
	 * Return a DNS response
	 *
	 * @param int $code
	 * @param array $answer
	 * @param array $authority
	 * @param array $additional
	 * @return bool
	 * @throws \Exception
	 */
	private function reply(int $code,array $answer=[],array $authority=[],array $additional=[]): bool
	{
		$header = (1 << 15);		// 1b: Query/Response
		$header |= ($this->query->header & 0xf) << 11;		// 4b: Opcode
		$header |= (1 << 10);		// 1b: Authoritative Answer
		$header |= (0 << 9);		// 1b: Truncated
		$header |= ((($this->query->header >> 8) & 1) << 8);		// 1b: Recursion Desired (in queries)
		$header |= (0 << 7);		// 1b: Recursion Available (in responses)
		$header |= (0 << 4);		// 3b: Zero (future, should be zero)
		$header |= ($code & 0xf);	// 4b: Result Code

		$q = $this->query->dns ? 1 : 0;
		$r = count($answer);
		$nscount = count($authority);
		$arcount = count($additional);

		$reply = pack('nnnnnn',$this->query->id,$header,$q,$r,$nscount,$arcount);

		// Return the answer
		if ($r) {
			// Question
			$reply .= $this->query->dns;

			// @todo In the case we return a CNAME and an A record, this should reference the CNAME domain when returning the A record
			foreach ($answer as $item => $type) {
				$rr = $this->rr($this->compress(12),unserialize($item),$type,self::DEFAULT_TTL);
				$reply .= $rr;
			}

		} else {
			$reply .= $this->query->dns;
		}

		foreach ($authority as $item => $type) {
			$rr = $this->rr($this->compress(12),unserialize($item),$type,self::DEFAULT_TTL);
			$reply .= $rr;
		}

		foreach ($additional as $item => $type) {
			$rr = $this->rr($this->compress(12),unserialize($item),$type,self::DEFAULT_TTL);
			$reply .= $rr;
		}

		if (! $this->client->send($reply,0)) {
			Log::error(sprintf('%s:! Error [%s] sending DNS reply to [%s:%d]',
				self::LOGKEY,
				socket_strerror(socket_last_error()),
				$this->client->address_remote,
				$this->client->port_remote
			));

			return FALSE;
		}

		return TRUE;
	}

	/**
	 * Return a DNS Resource Record
	 *
	 * @param string $query	- Domain in the query
	 * @param mixed $ars - Answer resources
	 * @param int $type - Resource type
	 * @param int $ttl - Time to live
	 * @return string
	 */
	private function rr(string $query,mixed $ars,int $type,int $ttl): string
	{
		// Reference the domain query in the question
		$reply = $query;

		// Record Type
		$reply .= pack('n',$type);

		// Internet
		$reply .= pack('n',self::DNS_QUERY_IN);

		// TTL
		$reply .= pack('N',$ttl);

		// Answer
		$a = '';
		switch ($type) {
			case self::DNS_TYPE_NS:
			case self::DNS_TYPE_CNAME:
			case self::DNS_TYPE_A:
			case self::DNS_TYPE_AAAA:
				$a .= $ars;

				break;

			case self::DNS_TYPE_SOA:
				$a .= $ars[0];
				$a .= $ars[1];
				$a .= pack('NNNNN',$ars[2],$ars[3],$ars[4],$ars[5],$ars[6]);

				break;

			case self::DNS_TYPE_SRV:
				$a .= pack('nnn',$ars[0],$ars[1],$ars[2]);
				$a .= $ars[3];

				break;

			case self::DNS_TYPE_TXT:
				$a .= pack('C',strlen($ars)).$ars;
				break;
		}

		$reply .= pack('n',strlen($a)).$a;

		return $reply;
	}

	private function soa(): array
	{
		return
			[serialize([
				$this->domain_split(config('fido.dns_ns')),
				$this->domain_split(Str::replace('@','.',config('app.mail.mail_from','nobody@'.gethostname()))),
				1,					// Serial
				self::DEFAULT_TTL,	// Refresh
				self::DEFAULT_TTL,	// Retry
				self::DEFAULT_TTL*7,// Expire
				self::DEFAULT_TTL	// Minimum cache
			]) => self::DNS_TYPE_SOA];
	}
}